Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c61c1cb2c | |||
| ccae467ceb | |||
| 55b36e5e62 | |||
| 8958fd312d | |||
| 34834f2eaa | |||
| 3ac1947106 |
39 changed files with 6113 additions and 4537 deletions
24
README.md
24
README.md
|
|
@ -140,7 +140,7 @@ http://localhost:8080
|
|||
**Standard-Anmeldedaten:**
|
||||
|
||||
- Benutzername: `admin`
|
||||
- Passwort: `admin123`
|
||||
- Passwort: Das in `docker-compose.yml` unter `INITIAL_ADMIN_PASSWORD` festgelegte Passwort.
|
||||
|
||||
⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung!
|
||||
|
||||
|
|
@ -179,13 +179,15 @@ export JWT_SECRET=development-secret
|
|||
|
||||
### 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** |
|
||||
| `TZ` | Zeitzone | `Europe/Berlin` | Nein |
|
||||
| `ENVIRONMENT` | `production` für HTTPS-Redirect | - | Nein |
|
||||
| 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
|
||||
|
||||
|
|
@ -203,7 +205,7 @@ Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert.
|
|||
|
||||
### Ersteinrichtung als Administrator
|
||||
|
||||
1. **Anmelden** mit den Standard-Credentials (admin/admin123)
|
||||
1. **Anmelden** mit den Standard-Credentials (admin/das initiale Passwort aus der Konfiguration)
|
||||
|
||||
2. **Admin-Passwort ändern**:
|
||||
- Gehe zu "Benutzer" Tab
|
||||
|
|
@ -311,7 +313,7 @@ Benutzer-Anmeldung
|
|||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
"password": "<your-initial-admin-password>"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -768,6 +770,6 @@ Todo
|
|||
|
||||
---
|
||||
|
||||
**Version**: 1.1.0
|
||||
**Version**: 1.5.0
|
||||
**Letztes Update**: November 2025
|
||||
**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter
|
||||
|
|
|
|||
|
|
@ -580,3 +580,47 @@ func calculateHours(entry TimeEntry) float64 {
|
|||
return calculateHoursDiff(entry.StartTime, entry.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteSchoolYear(db *sql.DB, id int) error {
|
||||
var isActive bool
|
||||
err := db.QueryRow("SELECT is_active FROM school_years WHERE id = ?", id).Scan(&isActive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isActive {
|
||||
return fmt.Errorf("cannot delete active school year")
|
||||
}
|
||||
|
||||
result, err := db.Exec("DELETE FROM school_years WHERE id = ? AND is_active = 0", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error {
|
||||
dates := calculateWeekDates(year, week)
|
||||
var dateList []string
|
||||
for day := 0; day <= 4; day++ {
|
||||
dateList = append(dateList, dates.Dates[fmt.Sprint(day)])
|
||||
}
|
||||
|
||||
query := `DELETE FROM time_entries
|
||||
WHERE user_id = ?
|
||||
AND type != 'manual'
|
||||
AND date IN (?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
205
backend/errors.go
Normal file
205
backend/errors.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
// Authentifizierung
|
||||
ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
|
||||
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
|
||||
ErrTokenExpired ErrorCode = "TOKEN_EXPIRED"
|
||||
ErrAccessDenied ErrorCode = "ACCESS_DENIED"
|
||||
|
||||
// Validierung
|
||||
ErrInvalidInput ErrorCode = "INVALID_INPUT"
|
||||
ErrMissingField ErrorCode = "MISSING_FIELD"
|
||||
ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT"
|
||||
ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT"
|
||||
|
||||
// Ressourcen
|
||||
ErrNotFound ErrorCode = "NOT_FOUND"
|
||||
ErrAlreadyExists ErrorCode = "ALREADY_EXISTS"
|
||||
ErrCannotDelete ErrorCode = "CANNOT_DELETE"
|
||||
ErrProtectedUser ErrorCode = "PROTECTED_USER"
|
||||
ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR"
|
||||
|
||||
// Datenbank
|
||||
ErrDatabase ErrorCode = "DATABASE_ERROR"
|
||||
ErrTransaction ErrorCode = "TRANSACTION_ERROR"
|
||||
ErrQueryFailed ErrorCode = "QUERY_FAILED"
|
||||
|
||||
// Server
|
||||
ErrInternal ErrorCode = "INTERNAL_ERROR"
|
||||
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
type AppError struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
UserMsg string `json:"user_message"`
|
||||
HTTPStatus int `json:"-"`
|
||||
Internal error `json:"-"`
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
if e.Internal != nil {
|
||||
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
|
||||
}
|
||||
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError {
|
||||
return &AppError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
UserMsg: userMsg,
|
||||
HTTPStatus: httpStatus,
|
||||
Internal: internal,
|
||||
}
|
||||
}
|
||||
|
||||
func ErrInvalidCredentialsMsg() *AppError {
|
||||
return NewAppError(
|
||||
ErrInvalidCredentials,
|
||||
"Invalid username or password",
|
||||
"Benutzername oder Passwort ungültig",
|
||||
http.StatusUnauthorized,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrUnauthorizedMsg() *AppError {
|
||||
return NewAppError(
|
||||
ErrUnauthorized,
|
||||
"Unauthorized access",
|
||||
"Keine Berechtigung für diese Aktion",
|
||||
http.StatusUnauthorized,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrTokenExpiredMsg() *AppError {
|
||||
return NewAppError(
|
||||
ErrTokenExpired,
|
||||
"Token has expired",
|
||||
"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an",
|
||||
http.StatusUnauthorized,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrAccessDeniedMsg() *AppError {
|
||||
return NewAppError(
|
||||
ErrAccessDenied,
|
||||
"Access denied - admin privileges required",
|
||||
"Zugriff verweigert. Administrator-Rechte erforderlich",
|
||||
http.StatusForbidden,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrInvalidInputMsg(field string) *AppError {
|
||||
return NewAppError(
|
||||
ErrInvalidInput,
|
||||
fmt.Sprintf("Invalid input for field: %s", field),
|
||||
fmt.Sprintf("Ungültige Eingabe im Feld: %s", field),
|
||||
http.StatusBadRequest,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrMissingFieldMsg(field string) *AppError {
|
||||
return NewAppError(
|
||||
ErrMissingField,
|
||||
fmt.Sprintf("Required field missing: %s", field),
|
||||
fmt.Sprintf("Pflichtfeld fehlt: %s", field),
|
||||
http.StatusBadRequest,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrNotFoundMsg(resource string) *AppError {
|
||||
return NewAppError(
|
||||
ErrNotFound,
|
||||
fmt.Sprintf("%s not found", resource),
|
||||
fmt.Sprintf("%s nicht gefunden", resource),
|
||||
http.StatusNotFound,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrAlreadyExistsMsg(resource string) *AppError {
|
||||
return NewAppError(
|
||||
ErrAlreadyExists,
|
||||
fmt.Sprintf("%s already exists", resource),
|
||||
fmt.Sprintf("%s existiert bereits", resource),
|
||||
http.StatusConflict,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrCannotDeleteMsg(resource, reason string) *AppError {
|
||||
return NewAppError(
|
||||
ErrCannotDelete,
|
||||
fmt.Sprintf("Cannot delete %s: %s", resource, reason),
|
||||
fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason),
|
||||
http.StatusBadRequest,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrProtectedUserMsg() *AppError {
|
||||
return NewAppError(
|
||||
ErrProtectedUser,
|
||||
"Cannot modify protected admin user",
|
||||
"Der Admin-Benutzer ist geschützt und kann nicht geändert werden",
|
||||
http.StatusForbidden,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrNoActiveSchoolYearMsg() *AppError {
|
||||
return NewAppError(
|
||||
ErrNoActiveSchool,
|
||||
"No active school year configured",
|
||||
"Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr",
|
||||
http.StatusNotFound,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrDatabaseMsg(internal error) *AppError {
|
||||
return NewAppError(
|
||||
ErrDatabase,
|
||||
"Database operation failed",
|
||||
"Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut",
|
||||
http.StatusInternalServerError,
|
||||
internal,
|
||||
)
|
||||
}
|
||||
|
||||
func ErrInternalMsg(internal error) *AppError {
|
||||
return NewAppError(
|
||||
ErrInternal,
|
||||
"Internal server error",
|
||||
"Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut",
|
||||
http.StatusInternalServerError,
|
||||
internal,
|
||||
)
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *AppError) ToResponse() ErrorResponse {
|
||||
return ErrorResponse{
|
||||
Code: e.Code,
|
||||
Message: e.UserMsg,
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ require (
|
|||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
|
@ -11,6 +13,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
|||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00=
|
||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package main
|
|||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
|
@ -15,24 +18,60 @@ type App struct {
|
|||
DB *sql.DB
|
||||
}
|
||||
|
||||
func HandleError(c echo.Context, err *AppError) error {
|
||||
log.Printf("[%s] %s", err.Code, err.Error())
|
||||
|
||||
return c.JSON(err.HTTPStatus, err.ToResponse())
|
||||
}
|
||||
|
||||
func getClaims(c echo.Context) (*Claims, error) {
|
||||
user, ok := c.Get("user").(*jwt.Token)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("JWT token missing or invalid")
|
||||
}
|
||||
|
||||
claims, ok := user.Claims.(*Claims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to parse JWT claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func isDuplicateError(err error) bool {
|
||||
return err != nil && (err.Error() == "UNIQUE constraint failed" ||
|
||||
strings.Contains(err.Error(), "UNIQUE") ||
|
||||
strings.Contains(err.Error(), "duplicate"))
|
||||
}
|
||||
|
||||
func (app *App) LoginHandler(c echo.Context) error {
|
||||
var req LoginRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||
return HandleError(c, ErrInvalidInputMsg("Login-Daten"))
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
|
||||
}
|
||||
if req.Password == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Passwort"))
|
||||
}
|
||||
|
||||
user, err := GetUserByUsername(app.DB, req.Username)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrInvalidCredentialsMsg())
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
|
||||
return HandleError(c, ErrInvalidCredentialsMsg())
|
||||
}
|
||||
|
||||
token, err := createToken(user.ID, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "error creating token")
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
response := LoginResponse{
|
||||
|
|
@ -47,7 +86,7 @@ func (app *App) LoginHandler(c echo.Context) error {
|
|||
func (app *App) GetSchedulesHandler(c echo.Context) error {
|
||||
schedules, err := GetAllSchedules(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
return c.JSON(http.StatusOK, schedules)
|
||||
}
|
||||
|
|
@ -55,24 +94,40 @@ func (app *App) GetSchedulesHandler(c echo.Context) error {
|
|||
func (app *App) CreateScheduleHandler(c echo.Context) error {
|
||||
var schedule Schedule
|
||||
if err := c.Bind(&schedule); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||
return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten"))
|
||||
}
|
||||
|
||||
if schedule.StartTime == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
|
||||
}
|
||||
if schedule.EndTime == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
|
||||
}
|
||||
if schedule.Title == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Titel"))
|
||||
}
|
||||
|
||||
if err := CreateSchedule(app.DB, &schedule); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if isDuplicateError(err) {
|
||||
return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"})
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"})
|
||||
}
|
||||
|
||||
func (app *App) DeleteScheduleHandler(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.QueryParam("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
|
||||
return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID"))
|
||||
}
|
||||
|
||||
if err := DeleteSchedule(app.DB, id); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Stundenplan"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
|
@ -81,7 +136,7 @@ func (app *App) DeleteScheduleHandler(c echo.Context) error {
|
|||
func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
|
||||
hours, err := GetYearlyHoursSummary(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if hours == nil {
|
||||
hours = []WeeklyHours{}
|
||||
|
|
@ -90,11 +145,6 @@ func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
|
|||
}
|
||||
|
||||
func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
|
||||
isAdmin, _ := c.Get("is_admin").(bool)
|
||||
if !isAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Only admins can create entries for others")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID int `json:"user_id"`
|
||||
Date string `json:"date"`
|
||||
|
|
@ -103,7 +153,17 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
|
|||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
||||
}
|
||||
|
||||
if req.UserID == 0 {
|
||||
return HandleError(c, ErrMissingFieldMsg("Benutzer"))
|
||||
}
|
||||
if req.Date == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Datum"))
|
||||
}
|
||||
if req.Hours == 0 {
|
||||
return HandleError(c, ErrMissingFieldMsg("Stunden"))
|
||||
}
|
||||
|
||||
entry := TimeEntry{
|
||||
|
|
@ -115,16 +175,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
|
|||
}
|
||||
|
||||
if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusCreated)
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *App) GetUsersHandler(c echo.Context) error {
|
||||
users, err := GetAllUsers(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if users == nil {
|
||||
users = []User{}
|
||||
|
|
@ -135,39 +195,62 @@ func (app *App) GetUsersHandler(c echo.Context) error {
|
|||
func (app *App) DeleteUserHandler(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.QueryParam("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
|
||||
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
|
||||
}
|
||||
|
||||
if id == 1 {
|
||||
return HandleError(c, ErrProtectedUserMsg())
|
||||
}
|
||||
|
||||
if err := DeleteUser(app.DB, id); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *App) CreateTimeEntryHandler(c echo.Context) error {
|
||||
userID := c.Get("user_id").(int)
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
var entry TimeEntry
|
||||
if err := c.Bind(&entry); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
||||
}
|
||||
|
||||
entry.UserID = userID
|
||||
if entry.Date == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Datum"))
|
||||
}
|
||||
if entry.StartTime == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
|
||||
}
|
||||
if entry.EndTime == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
|
||||
}
|
||||
|
||||
entry.UserID = claims.UserID
|
||||
|
||||
if err := CreateTimeEntry(app.DB, &entry); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"})
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"})
|
||||
}
|
||||
|
||||
func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
|
||||
userID := c.Get("user_id").(int)
|
||||
|
||||
entries, err := GetTimeEntriesByUser(app.DB, userID)
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
entries, err := GetTimeEntriesByUser(app.DB, claims.UserID)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []TimeEntry{}
|
||||
|
|
@ -179,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
|
|||
func (app *App) GetWeekDates(c echo.Context) error {
|
||||
year, err := strconv.Atoi(c.QueryParam("year"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||
return HandleError(c, ErrInvalidInputMsg("Jahr"))
|
||||
}
|
||||
|
||||
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||
return HandleError(c, ErrInvalidInputMsg("Woche"))
|
||||
}
|
||||
|
||||
if week < 1 || week > 53 {
|
||||
return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)"))
|
||||
}
|
||||
|
||||
dates := calculateWeekDates(year, week)
|
||||
|
|
@ -192,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error {
|
|||
}
|
||||
|
||||
func (app *App) CheckWeekHasEntries(c echo.Context) error {
|
||||
userID := c.Get("user_id").(int)
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
year, err := strconv.Atoi(c.QueryParam("year"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||
return HandleError(c, ErrInvalidInputMsg("Jahr"))
|
||||
}
|
||||
|
||||
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||
return HandleError(c, ErrInvalidInputMsg("Woche"))
|
||||
}
|
||||
|
||||
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week)
|
||||
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries})
|
||||
|
|
@ -215,7 +305,7 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error {
|
|||
func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
|
||||
entries, err := GetAllTimeEntries(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []TimeEntry{}
|
||||
|
|
@ -226,7 +316,7 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
|
|||
func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
|
||||
hours, err := GetWeeklyHours(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if hours == nil {
|
||||
hours = []WeeklyHours{}
|
||||
|
|
@ -235,20 +325,23 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
|
|||
}
|
||||
|
||||
func (app *App) DeleteWeekEntries(c echo.Context) error {
|
||||
userID := c.Get("user_id").(int)
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
year, err := strconv.Atoi(c.QueryParam("year"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||
return HandleError(c, ErrInvalidInputMsg("Jahr"))
|
||||
}
|
||||
|
||||
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||
return HandleError(c, ErrInvalidInputMsg("Woche"))
|
||||
}
|
||||
|
||||
if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
|
@ -310,52 +403,83 @@ type BatchTimeEntryRequest struct {
|
|||
}
|
||||
|
||||
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
|
||||
userID := c.Get("user_id").(int)
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
var req BatchTimeEntryRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
||||
}
|
||||
|
||||
if len(req.Entries) == 0 {
|
||||
return HandleError(c, ErrMissingFieldMsg("Zeiteinträge"))
|
||||
}
|
||||
|
||||
if len(req.Entries) > 0 {
|
||||
firstDate := req.Entries[0].Date
|
||||
t, err := time.Parse("2006-01-02", firstDate)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("Datum-Format"))
|
||||
}
|
||||
year, week := t.ISOWeek()
|
||||
|
||||
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := app.DB.Begin()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "transaction error")
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "prepare error")
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, entry := range req.Entries {
|
||||
_, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
|
||||
_, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "insert error")
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "commit error")
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"})
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"})
|
||||
}
|
||||
|
||||
func (app *App) UpdateUserHandler(c echo.Context) error {
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID")
|
||||
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return HandleError(c, ErrProtectedUserMsg())
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
|
||||
}
|
||||
|
||||
if req.YearlyHours <= 0 {
|
||||
return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)"))
|
||||
}
|
||||
|
||||
if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
|
|
@ -364,21 +488,28 @@ func (app *App) UpdateUserHandler(c echo.Context) error {
|
|||
func (app *App) ResetPasswordHandler(c echo.Context) error {
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID")
|
||||
return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
|
||||
}
|
||||
|
||||
var req ResetPasswordRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
return HandleError(c, ErrInvalidInputMsg("Passwort-Daten"))
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 6 {
|
||||
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password")
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
|
|
@ -387,16 +518,29 @@ func (app *App) ResetPasswordHandler(c echo.Context) error {
|
|||
func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
|
||||
entryID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID")
|
||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
|
||||
}
|
||||
|
||||
var req UpdateTimeEntryRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
||||
}
|
||||
|
||||
if req.Date == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Datum"))
|
||||
}
|
||||
if req.StartTime == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
|
||||
}
|
||||
if req.EndTime == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
|
||||
}
|
||||
|
||||
if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
|
|
@ -405,22 +549,31 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
|
|||
func (app *App) DeleteTimeEntryHandler(c echo.Context) error {
|
||||
entryID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID")
|
||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
|
||||
}
|
||||
|
||||
if err := DeleteTimeEntry(app.DB, entryID); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *App) GetMyInfoHandler(c echo.Context) error {
|
||||
userID := c.Get("user_id").(int)
|
||||
|
||||
user, err := GetUserByID(app.DB, userID)
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
user, err := GetUserByID(app.DB, claims.UserID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, user)
|
||||
|
|
@ -429,12 +582,22 @@ func (app *App) GetMyInfoHandler(c echo.Context) error {
|
|||
func (app *App) CreateUserHandler(c echo.Context) error {
|
||||
var req CreateUserRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
|
||||
}
|
||||
|
||||
if req.Username == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
|
||||
}
|
||||
if req.Password == "" {
|
||||
return HandleError(c, ErrMissingFieldMsg("Passwort"))
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password")
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
if req.YearlyHours == 0 {
|
||||
|
|
@ -442,7 +605,10 @@ func (app *App) CreateUserHandler(c echo.Context) error {
|
|||
}
|
||||
|
||||
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if isDuplicateError(err) {
|
||||
return HandleError(c, ErrAlreadyExistsMsg("Benutzername"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusCreated)
|
||||
|
|
@ -451,7 +617,7 @@ func (app *App) CreateUserHandler(c echo.Context) error {
|
|||
func (app *App) GetSchoolYearsHandler(c echo.Context) error {
|
||||
years, err := GetAllSchoolYears(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if years == nil {
|
||||
years = []SchoolYear{}
|
||||
|
|
@ -462,11 +628,24 @@ func (app *App) GetSchoolYearsHandler(c echo.Context) error {
|
|||
func (app *App) CreateSchoolYearHandler(c echo.Context) error {
|
||||
var req CreateSchoolYearRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
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 {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if isDuplicateError(err) {
|
||||
return HandleError(c, ErrAlreadyExistsMsg("Schuljahr"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusCreated)
|
||||
|
|
@ -475,11 +654,14 @@ func (app *App) CreateSchoolYearHandler(c echo.Context) error {
|
|||
func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID")
|
||||
return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID"))
|
||||
}
|
||||
|
||||
if err := SetActiveSchoolYear(app.DB, id); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
if err == sql.ErrNoRows {
|
||||
return HandleError(c, ErrNotFoundMsg("Schuljahr"))
|
||||
}
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
|
|
@ -488,7 +670,7 @@ func (app *App) SetActiveSchoolYearHandler(c echo.Context) error {
|
|||
func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
|
||||
year, err := GetActiveSchoolYear(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if year == nil {
|
||||
return c.JSON(http.StatusOK, map[string]any{"active": false})
|
||||
|
|
@ -497,24 +679,22 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error {
|
|||
}
|
||||
|
||||
func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error {
|
||||
isAdmin, _ := c.Get("is_admin").(bool)
|
||||
if !isAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs")
|
||||
}
|
||||
|
||||
schoolYear, err := GetActiveSchoolYear(app.DB)
|
||||
if err != nil || schoolYear == nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "No active school year found")
|
||||
if err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
if schoolYear == nil {
|
||||
return HandleError(c, ErrNoActiveSchoolYearMsg())
|
||||
}
|
||||
|
||||
summary, err := GetYearlyHoursSummary(app.DB)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error())
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name)
|
||||
|
|
@ -523,3 +703,26 @@ func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error {
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
|
|
@ -24,8 +25,20 @@ func main() {
|
|||
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
// CORS Configuration
|
||||
allowOrigins := []string{"*"} // Default for development
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||
if origins != "" {
|
||||
allowOrigins = strings.Split(origins, ",")
|
||||
} else {
|
||||
log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.")
|
||||
}
|
||||
}
|
||||
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowOrigins: allowOrigins,
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
|
||||
}))
|
||||
|
|
@ -67,6 +80,7 @@ func main() {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
echojwt "github.com/labstack/echo-jwt/v4"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"golang.org/x/time/rate"
|
||||
|
|
@ -28,104 +24,43 @@ func init() {
|
|||
}
|
||||
|
||||
func createToken(userID int, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
|
||||
},
|
||||
}
|
||||
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
|
||||
claimsWithExp := map[string]any{
|
||||
"user_id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"is_admin": claims.IsAdmin,
|
||||
"exp": time.Now().Add(2 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(claimsWithExp)
|
||||
payloadEncoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
|
||||
message := header + "." + payloadEncoded
|
||||
|
||||
h := hmac.New(sha256.New, jwtSecret)
|
||||
h.Write([]byte(message))
|
||||
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
return message + "." + signature, nil
|
||||
}
|
||||
|
||||
func verifyToken(tokenString string) (*Claims, error) {
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid token format")
|
||||
}
|
||||
|
||||
message := parts[0] + "." + parts[1]
|
||||
h := hmac.New(sha256.New, jwtSecret)
|
||||
h.Write([]byte(message))
|
||||
expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
if parts[2] != expectedSignature {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claimsMap map[string]any
|
||||
if err := json.Unmarshal(payload, &claimsMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if exp, ok := claimsMap["exp"].(float64); ok {
|
||||
if time.Now().Unix() > int64(exp) {
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
}
|
||||
|
||||
claims := &Claims{
|
||||
UserID: int(claimsMap["user_id"].(float64)),
|
||||
Username: claimsMap["username"].(string),
|
||||
IsAdmin: claimsMap["is_admin"].(bool),
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
func JWTMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := verifyToken(tokenString)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
|
||||
c.Logger().Infof("Authenticated user: ID=%d, Username=%s", claims.UserID, claims.Username)
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
return echojwt.WithConfig(echojwt.Config{
|
||||
NewClaimsFunc: func(c echo.Context) jwt.Claims {
|
||||
return new(Claims)
|
||||
},
|
||||
SigningKey: jwtSecret,
|
||||
})
|
||||
}
|
||||
|
||||
func AdminMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
isAdmin, ok := c.Get("is_admin").(bool)
|
||||
if !ok || !isAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Access denied")
|
||||
user, ok := c.Get("user").(*jwt.Token)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid")
|
||||
}
|
||||
|
||||
claims, ok := user.Claims.(*Claims)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims")
|
||||
}
|
||||
|
||||
if !claims.IsAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package main
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TimeEntry struct {
|
||||
ID int `json:"id"`
|
||||
|
|
@ -96,4 +99,5 @@ type Claims struct {
|
|||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +1,338 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Zeiterfassung</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
|
||||
<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>
|
||||
/* Toast-Container */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Basis-Toast */
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: all;
|
||||
min-width: 320px;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.toast:hover {
|
||||
transform: translateX(-5px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Toast-Content */
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close-Button */
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
transition: color 0.2s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Toast-Typen */
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
|
||||
border-left-color: #e53e3e;
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
|
||||
border-left-color: #38a169;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
|
||||
border-left-color: #3182ce;
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3182ce;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
|
||||
border-left-color: #dd6b20;
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #dd6b20;
|
||||
}
|
||||
|
||||
/* Animationen */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.dismissing {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Mobile Anpassungen */
|
||||
@media screen and (max-width: 768px) {
|
||||
.toast-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support (optional) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.toast {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.level {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-left, .level-right {
|
||||
|
||||
.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); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="elm"></div>
|
||||
|
||||
|
||||
<script src="/elm.js"></script>
|
||||
<script>
|
||||
function getStoredData() {
|
||||
try {
|
||||
const data = localStorage.getItem('timetracking');
|
||||
const data = localStorage.getItem("timetracking");
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored data:', e);
|
||||
console.error("Failed to parse stored data:", e);
|
||||
}
|
||||
return { token: null, isAdmin: false };
|
||||
return {token: null, isAdmin: false};
|
||||
}
|
||||
|
||||
|
||||
function saveData(token, isAdmin) {
|
||||
try {
|
||||
localStorage.setItem('timetracking', JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin
|
||||
}));
|
||||
localStorage.setItem(
|
||||
"timetracking",
|
||||
JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to save data:', e);
|
||||
console.error("Failed to save data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function clearData() {
|
||||
try {
|
||||
localStorage.removeItem('timetracking');
|
||||
localStorage.removeItem("timetracking");
|
||||
} catch (e) {
|
||||
console.error('Failed to clear data:', e);
|
||||
console.error("Failed to clear data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const storedData = getStoredData();
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById('elm'),
|
||||
node: document.getElementById("elm"),
|
||||
flags: {
|
||||
token: storedData.token,
|
||||
isAdmin: storedData.isAdmin
|
||||
}
|
||||
isAdmin: storedData.isAdmin,
|
||||
},
|
||||
});
|
||||
|
||||
app.ports.saveToken.subscribe(function(data) {
|
||||
|
||||
app.ports.saveToken.subscribe(function (data) {
|
||||
saveData(data.token, data.isAdmin);
|
||||
});
|
||||
|
||||
app.ports.removeToken.subscribe(function() {
|
||||
|
||||
app.ports.removeToken.subscribe(function () {
|
||||
clearData();
|
||||
});
|
||||
|
||||
app.ports.confirmDelete.subscribe(function(message) {
|
||||
|
||||
app.ports.confirmDelete.subscribe(function (message) {
|
||||
const confirmed = confirm(message);
|
||||
app.ports.confirmDeleteResponse.send(confirmed);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
function setupBurgerMenu() {
|
||||
const burgers = document.querySelectorAll('.navbar-burger');
|
||||
|
||||
burgers.forEach(burger => {
|
||||
burger.addEventListener('click', () => {
|
||||
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');
|
||||
burger.classList.toggle("is-active");
|
||||
menu.classList.toggle("is-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setupBurgerMenu();
|
||||
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
setupBurgerMenu();
|
||||
});
|
||||
|
||||
observer.observe(document.getElementById('elm'), {
|
||||
|
||||
observer.observe(document.getElementById("elm"), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator && window.location.protocol === 'https:') {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
console.log('Service Worker registration failed');
|
||||
|
||||
if (
|
||||
"serviceWorker" in navigator &&
|
||||
window.location.protocol === "https:"
|
||||
) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
console.log("Service Worker registration failed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,164 +1,338 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Zeiterfassung</title>
|
||||
|
||||
<!-- Bulma CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.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>
|
||||
/* Custom Styles */
|
||||
/* Toast-Container */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Basis-Toast */
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: all;
|
||||
min-width: 320px;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.toast:hover {
|
||||
transform: translateX(-5px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Toast-Content */
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close-Button */
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
transition: color 0.2s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Toast-Typen */
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
|
||||
border-left-color: #e53e3e;
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
|
||||
border-left-color: #38a169;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
|
||||
border-left-color: #3182ce;
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3182ce;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
|
||||
border-left-color: #dd6b20;
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #dd6b20;
|
||||
}
|
||||
|
||||
/* Animationen */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.dismissing {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Mobile Anpassungen */
|
||||
@media screen and (max-width: 768px) {
|
||||
.toast-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support (optional) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.toast {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Responsive Verbesserungen */
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.level {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-left, .level-right {
|
||||
|
||||
.level-left,
|
||||
.level-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.level-item {
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
.button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
|
||||
.fa-spinner {
|
||||
animation: fa-spin 1s infinite linear;
|
||||
}
|
||||
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="elm"></div>
|
||||
|
||||
|
||||
<script src="/elm.js"></script>
|
||||
<script>
|
||||
// LocalStorage Helper
|
||||
function getStoredData() {
|
||||
try {
|
||||
const data = localStorage.getItem('timetracking');
|
||||
const data = localStorage.getItem("timetracking");
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored data:', e);
|
||||
console.error("Failed to parse stored data:", e);
|
||||
}
|
||||
return { token: null, isAdmin: false };
|
||||
return {token: null, isAdmin: false};
|
||||
}
|
||||
|
||||
|
||||
function saveData(token, isAdmin) {
|
||||
try {
|
||||
localStorage.setItem('timetracking', JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin
|
||||
}));
|
||||
localStorage.setItem(
|
||||
"timetracking",
|
||||
JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to save data:', e);
|
||||
console.error("Failed to save data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function clearData() {
|
||||
try {
|
||||
localStorage.removeItem('timetracking');
|
||||
localStorage.removeItem("timetracking");
|
||||
} catch (e) {
|
||||
console.error('Failed to clear data:', e);
|
||||
console.error("Failed to clear data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisiere Elm App mit gespeicherten Daten
|
||||
|
||||
const storedData = getStoredData();
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById('elm'),
|
||||
node: document.getElementById("elm"),
|
||||
flags: {
|
||||
token: storedData.token,
|
||||
isAdmin: storedData.isAdmin
|
||||
}
|
||||
isAdmin: storedData.isAdmin,
|
||||
},
|
||||
});
|
||||
|
||||
// Port: Token speichern
|
||||
app.ports.saveToken.subscribe(function(data) {
|
||||
|
||||
app.ports.saveToken.subscribe(function (data) {
|
||||
saveData(data.token, data.isAdmin);
|
||||
});
|
||||
|
||||
// Port: Token entfernen
|
||||
app.ports.removeToken.subscribe(function() {
|
||||
|
||||
app.ports.removeToken.subscribe(function () {
|
||||
clearData();
|
||||
});
|
||||
|
||||
// Port: Lösch-Bestätigung
|
||||
app.ports.confirmDelete.subscribe(function(message) {
|
||||
|
||||
app.ports.confirmDelete.subscribe(function (message) {
|
||||
const confirmed = confirm(message);
|
||||
app.ports.confirmDeleteResponse.send(confirmed);
|
||||
});
|
||||
|
||||
// BUGFIX: Responsive Navbar Toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Funktion für Burger-Menu
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
function setupBurgerMenu() {
|
||||
const burgers = document.querySelectorAll('.navbar-burger');
|
||||
|
||||
burgers.forEach(burger => {
|
||||
burger.addEventListener('click', () => {
|
||||
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');
|
||||
burger.classList.toggle("is-active");
|
||||
menu.classList.toggle("is-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
|
||||
setupBurgerMenu();
|
||||
|
||||
// Observer für dynamische Änderungen (wenn Elm DOM updated)
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
setupBurgerMenu();
|
||||
});
|
||||
|
||||
observer.observe(document.getElementById('elm'), {
|
||||
|
||||
observer.observe(document.getElementById("elm"), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Service Worker für Offline-Fähigkeit (optional)
|
||||
if ('serviceWorker' in navigator && window.location.protocol === 'https:') {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
console.log('Service Worker registration failed');
|
||||
|
||||
if (
|
||||
"serviceWorker" in navigator &&
|
||||
window.location.protocol === "https:"
|
||||
) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
console.log("Service Worker registration failed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
21
frontend/src/Api/Auth.elm
Normal file
21
frontend/src/Api/Auth.elm
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
module Api.Auth exposing (loginRequest)
|
||||
|
||||
import Api.Decoders exposing (loginDecoder)
|
||||
import Http
|
||||
import Json.Encode as Encode
|
||||
import Types.Api exposing (LoginResult)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
loginRequest : String -> String -> Cmd Msg
|
||||
loginRequest username password =
|
||||
Http.post
|
||||
{ url = "/api/login"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "username", Encode.string username )
|
||||
, ( "password", Encode.string password )
|
||||
]
|
||||
, expect = Http.expectJson LoginResponse loginDecoder
|
||||
}
|
||||
109
frontend/src/Api/Decoders.elm
Normal file
109
frontend/src/Api/Decoders.elm
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
module Api.Decoders exposing
|
||||
( apiErrorDecoder
|
||||
, loginDecoder
|
||||
, scheduleDecoder
|
||||
, schoolYearDecoder
|
||||
, timeEntryDecoder
|
||||
, userDecoder
|
||||
, weekDatesDecoder
|
||||
, weeklyHoursDecoder
|
||||
, yearlyHoursSummaryDecoder
|
||||
)
|
||||
|
||||
import Dict
|
||||
import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string)
|
||||
import Types.Api exposing (ApiError, LoginResult)
|
||||
import Types.Model exposing (..)
|
||||
|
||||
|
||||
loginDecoder : Decoder LoginResult
|
||||
loginDecoder =
|
||||
Decode.map3 LoginResult
|
||||
(field "token" string)
|
||||
(field "username" string)
|
||||
(field "is_admin" bool)
|
||||
|
||||
|
||||
scheduleDecoder : Decoder Schedule
|
||||
scheduleDecoder =
|
||||
Decode.map6 Schedule
|
||||
(field "id" int)
|
||||
(field "day_of_week" int)
|
||||
(field "start_time" string)
|
||||
(field "end_time" string)
|
||||
(field "type" string)
|
||||
(field "title" string)
|
||||
|
||||
|
||||
timeEntryDecoder : Decoder TimeEntry
|
||||
timeEntryDecoder =
|
||||
Decode.map8 TimeEntry
|
||||
(field "id" int)
|
||||
(field "user_id" int)
|
||||
(field "schedule_id" int)
|
||||
(field "date" string)
|
||||
(field "type" string)
|
||||
(field "username" string)
|
||||
(field "start_time" string)
|
||||
(field "end_time" string)
|
||||
|
||||
|
||||
userDecoder : Decoder User
|
||||
userDecoder =
|
||||
Decode.map4 User
|
||||
(field "id" int)
|
||||
(field "username" string)
|
||||
(field "is_admin" bool)
|
||||
(field "yearly_hours" float)
|
||||
|
||||
|
||||
weekDatesDecoder : Decoder WeekDates
|
||||
weekDatesDecoder =
|
||||
Decode.map4 WeekDates
|
||||
(field "year" int)
|
||||
(field "week" int)
|
||||
(field "dates" (Decode.dict string) |> Decode.map Dict.toList)
|
||||
(field "range" string)
|
||||
|
||||
|
||||
weeklyHoursDecoder : Decoder WeeklyHours
|
||||
weeklyHoursDecoder =
|
||||
Decode.map7 WeeklyHours
|
||||
(field "user_id" int)
|
||||
(field "username" string)
|
||||
(field "year" int)
|
||||
(field "week" int)
|
||||
(field "total_hours" float)
|
||||
(field "expected_hours" float)
|
||||
(field "remaining_hours" float)
|
||||
|
||||
|
||||
yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary
|
||||
yearlyHoursSummaryDecoder =
|
||||
Decode.succeed YearlyHoursSummary
|
||||
|> Decode.andThen (\f -> Decode.map f (field "user_id" int))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "username" string))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "year" int))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "week" int))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "total_hours" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "yearly_target" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "weekly_target" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float))
|
||||
|
||||
|
||||
schoolYearDecoder : Decoder SchoolYear
|
||||
schoolYearDecoder =
|
||||
Decode.map5 SchoolYear
|
||||
(field "id" int)
|
||||
(field "name" string)
|
||||
(field "start_date" string)
|
||||
(field "end_date" string)
|
||||
(field "is_active" bool)
|
||||
|
||||
|
||||
apiErrorDecoder : Decoder ApiError
|
||||
apiErrorDecoder =
|
||||
Decode.map2 ApiError
|
||||
(field "code" string)
|
||||
(field "message" string)
|
||||
120
frontend/src/Api/Schedule.elm
Normal file
120
frontend/src/Api/Schedule.elm
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
module Api.Schedule exposing
|
||||
( createSchedule
|
||||
, deleteSchedule
|
||||
, fetchSchedules
|
||||
, saveTimeEntriesForWeek
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (scheduleDecoder)
|
||||
import Http
|
||||
import Json.Decode
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchSchedules : Maybe String -> Cmd Msg
|
||||
fetchSchedules maybeToken =
|
||||
case maybeToken of
|
||||
Just token ->
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/schedules"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
createSchedule : String -> NewSchedule -> Cmd Msg
|
||||
createSchedule token schedule =
|
||||
case String.toInt schedule.dayOfWeek of
|
||||
Just day ->
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/schedules"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "day_of_week", Encode.int day )
|
||||
, ( "start_time", Encode.string schedule.startTime )
|
||||
, ( "end_time", Encode.string schedule.endTime )
|
||||
, ( "type", Encode.string schedule.scheduleType )
|
||||
, ( "title", Encode.string schedule.title )
|
||||
]
|
||||
, expect = Http.expectWhatever ScheduleCreated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
deleteSchedule : String -> Int -> Cmd Msg
|
||||
deleteSchedule token scheduleId =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever ScheduleDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg
|
||||
saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates =
|
||||
case maybeWeekDates of
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
Just weekDates ->
|
||||
let
|
||||
getScheduleById id =
|
||||
List.filter (\s -> s.id == id) schedules |> List.head
|
||||
|
||||
getDateForDay dayOfWeek =
|
||||
weekDates.dates
|
||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|
||||
createEntryData entry =
|
||||
case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of
|
||||
( Just schedule, Just dateStr ) ->
|
||||
Just <|
|
||||
Encode.object
|
||||
[ ( "schedule_id", Encode.int entry.scheduleId )
|
||||
, ( "date", Encode.string dateStr )
|
||||
, ( "type", Encode.string schedule.scheduleType )
|
||||
, ( "start_time", Encode.string schedule.startTime )
|
||||
, ( "end_time", Encode.string schedule.endTime )
|
||||
]
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
entriesData =
|
||||
List.filterMap createEntryData selectedEntries
|
||||
in
|
||||
if List.isEmpty entriesData then
|
||||
Cmd.none
|
||||
|
||||
else
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/time-entries/batch"
|
||||
, body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ]
|
||||
, expect = Http.expectWhatever TimeEntriesSaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
85
frontend/src/Api/SchoolYear.elm
Normal file
85
frontend/src/Api/SchoolYear.elm
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
module Api.SchoolYear exposing
|
||||
( activateSchoolYear
|
||||
, createSchoolYear
|
||||
, deleteSchoolYear
|
||||
, fetchActiveSchoolYear
|
||||
, fetchSchoolYears
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (schoolYearDecoder)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (NewSchoolYear)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchSchoolYears : String -> Cmd Msg
|
||||
fetchSchoolYears token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchActiveSchoolYear : String -> Cmd Msg
|
||||
fetchActiveSchoolYear token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/school-year/active"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
createSchoolYear : String -> NewSchoolYear -> Cmd Msg
|
||||
createSchoolYear token schoolYear =
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "name", Encode.string schoolYear.name )
|
||||
, ( "start_date", Encode.string schoolYear.startDate )
|
||||
, ( "end_date", Encode.string schoolYear.endDate )
|
||||
]
|
||||
, expect = Http.expectWhatever SchoolYearCreated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
activateSchoolYear : String -> Int -> Cmd Msg
|
||||
activateSchoolYear token id =
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever SchoolYearActivated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteSchoolYear : String -> Int -> Cmd Msg
|
||||
deleteSchoolYear token id =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years/" ++ String.fromInt id
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever SchoolYearDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
201
frontend/src/Api/TimeEntry.elm
Normal file
201
frontend/src/Api/TimeEntry.elm
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
module Api.TimeEntry exposing
|
||||
( checkWeekHasEntries
|
||||
, createAdminTimeEntry
|
||||
, deleteTimeEntry
|
||||
, deleteWeekEntries
|
||||
, downloadYearlySummaryPDF
|
||||
, fetchAllTimeEntries
|
||||
, fetchMyTimeEntries
|
||||
, fetchWeekDates
|
||||
, fetchWeeklyHours
|
||||
, fetchYearlyHoursSummary
|
||||
, updateTimeEntry
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder)
|
||||
import Bytes exposing (Bytes)
|
||||
import Http
|
||||
import Json.Decode as Decode exposing (bool, field)
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (AdminManualEntry, EditingTimeEntry)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchMyTimeEntries : String -> Cmd Msg
|
||||
fetchMyTimeEntries token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/my-time-entries"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchAllTimeEntries : String -> Cmd Msg
|
||||
fetchAllTimeEntries token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entries"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchWeekDates : String -> Int -> Int -> Cmd Msg
|
||||
fetchWeekDates token year week =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson WeekDatesReceived weekDatesDecoder
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
checkWeekHasEntries : String -> Int -> Int -> Cmd Msg
|
||||
checkWeekHasEntries token year week =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteWeekEntries : String -> Int -> Int -> Cmd Msg
|
||||
deleteWeekEntries token year week =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever WeekEntriesDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg
|
||||
updateTimeEntry token entry =
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "date", Encode.string entry.date )
|
||||
, ( "start_time", Encode.string entry.startTime )
|
||||
, ( "end_time", Encode.string entry.endTime )
|
||||
, ( "type", Encode.string entry.entryType )
|
||||
]
|
||||
, expect = Http.expectWhatever TimeEntrySaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteTimeEntry : String -> Int -> Cmd Msg
|
||||
deleteTimeEntry token entryId =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entries/" ++ String.fromInt entryId
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever TimeEntryDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg
|
||||
createAdminTimeEntry token entry =
|
||||
case entry.selectedUserId of
|
||||
Just userId ->
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entry"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "user_id", Encode.int userId )
|
||||
, ( "date", Encode.string entry.date )
|
||||
, ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) )
|
||||
, ( "type", Encode.string "manual" )
|
||||
]
|
||||
, expect = Http.expectWhatever AdminTimeEntrySaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
fetchYearlyHoursSummary : String -> Cmd Msg
|
||||
fetchYearlyHoursSummary token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/yearly-hours-summary"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
downloadYearlySummaryPDF : String -> Cmd Msg
|
||||
downloadYearlySummaryPDF token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/yearly-summary/pdf"
|
||||
, body = Http.emptyBody
|
||||
, expect =
|
||||
Http.expectBytesResponse YearlySummaryPDFReceived
|
||||
(\response ->
|
||||
case response of
|
||||
Http.GoodStatus_ _ body ->
|
||||
Ok body
|
||||
|
||||
Http.BadUrl_ url ->
|
||||
Err (Http.BadUrl url)
|
||||
|
||||
Http.Timeout_ ->
|
||||
Err Http.Timeout
|
||||
|
||||
Http.NetworkError_ ->
|
||||
Err Http.NetworkError
|
||||
|
||||
Http.BadStatus_ metadata _ ->
|
||||
Err (Http.BadStatus metadata.statusCode)
|
||||
)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchWeeklyHours : String -> Cmd Msg
|
||||
fetchWeeklyHours token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/weekly-hours"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
110
frontend/src/Api/User.elm
Normal file
110
frontend/src/Api/User.elm
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
module Api.User exposing
|
||||
( createUser
|
||||
, deleteUser
|
||||
, fetchMyInfo
|
||||
, fetchUsers
|
||||
, resetUserPassword
|
||||
, updateUserWorkHours
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (userDecoder)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (NewUser)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchUsers : String -> Cmd Msg
|
||||
fetchUsers token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/list"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson UsersReceived (Decode.list userDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchMyInfo : String -> Cmd Msg
|
||||
fetchMyInfo token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/my-info"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson MyInfoReceived userDecoder
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
createUser : String -> NewUser -> Cmd Msg
|
||||
createUser token user =
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "username", Encode.string user.username )
|
||||
, ( "password", Encode.string user.password )
|
||||
, ( "is_admin", Encode.bool user.isAdmin )
|
||||
]
|
||||
, expect = Http.expectWhatever UserCreated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteUser : String -> Int -> Cmd Msg
|
||||
deleteUser token userId =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/delete?id=" ++ String.fromInt userId
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever UserDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
updateUserWorkHours : String -> Int -> String -> Cmd Msg
|
||||
updateUserWorkHours token userId hours =
|
||||
case String.toFloat hours of
|
||||
Just workHours ->
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/" ++ String.fromInt userId
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "yearly_hours", Encode.float workHours ) ]
|
||||
, expect = Http.expectWhatever UserWorkHoursSaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
resetUserPassword : String -> Int -> String -> Cmd Msg
|
||||
resetUserPassword token userId newPassword =
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "new_password", Encode.string newPassword ) ]
|
||||
, expect = Http.expectWhatever ResetPasswordSaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
17
frontend/src/Types/Api.elm
Normal file
17
frontend/src/Types/Api.elm
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module Types.Api exposing
|
||||
( ApiError
|
||||
, LoginResult
|
||||
)
|
||||
|
||||
|
||||
type alias LoginResult =
|
||||
{ token : String
|
||||
, username : String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias ApiError =
|
||||
{ code : String
|
||||
, message : String
|
||||
}
|
||||
218
frontend/src/Types/Model.elm
Normal file
218
frontend/src/Types/Model.elm
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
module Types.Model exposing
|
||||
( AdminManualEntry
|
||||
, EditingTimeEntry
|
||||
, Flags
|
||||
, Model
|
||||
, NewSchedule
|
||||
, NewSchoolYear
|
||||
, NewUser
|
||||
, Schedule
|
||||
, SchoolYear
|
||||
, SelectedEntry
|
||||
, TimeEntry
|
||||
, Toast
|
||||
, ToastType(..)
|
||||
, User
|
||||
, WeekDates
|
||||
, WeeklyHours
|
||||
, WeeklySummary
|
||||
, YearlyHoursSummary
|
||||
)
|
||||
|
||||
import Time
|
||||
import Types.Page exposing (AdminTab, Page)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ page : Page
|
||||
, activeTab : AdminTab
|
||||
, username : String
|
||||
, password : String
|
||||
, token : Maybe String
|
||||
, isAdmin : Bool
|
||||
, schedules : List Schedule
|
||||
, users : List User
|
||||
, timeEntries : List TimeEntry
|
||||
, weeklyHours : List WeeklyHours
|
||||
, yearlyHoursSummary : List YearlyHoursSummary
|
||||
, selectedEntries : List SelectedEntry
|
||||
, currentWeek : Int
|
||||
, currentYear : Int
|
||||
, weekDates : Maybe WeekDates
|
||||
, currentTime : Time.Posix
|
||||
, zone : Time.Zone
|
||||
, newSchedule : NewSchedule
|
||||
, newUser : NewUser
|
||||
, error : Maybe String
|
||||
, weekEditMode : Bool
|
||||
, hasEntriesForCurrentWeek : Bool
|
||||
, userWeeklySummary : Maybe WeeklySummary
|
||||
, editingTimeEntryId : Maybe Int
|
||||
, editingTimeEntry : EditingTimeEntry
|
||||
, editingUserId : Maybe Int
|
||||
, editingUserWorkHours : String
|
||||
, resetPasswordUserId : Maybe Int
|
||||
, resetPasswordNew : String
|
||||
, pendingDeleteId : Maybe Int
|
||||
, selectedUserId : Maybe Int
|
||||
, userWorkHoursInput : String
|
||||
, userPasswordInput : String
|
||||
, isProcessing : Bool
|
||||
, mobileMenuOpen : Bool
|
||||
, adminManualEntryForm : AdminManualEntry
|
||||
, schoolYears : List SchoolYear
|
||||
, newSchoolYear : NewSchoolYear
|
||||
, activeSchoolYear : Maybe SchoolYear
|
||||
, editingSchoolYearId : Maybe Int
|
||||
, toasts : List Toast
|
||||
, nextToastId : Int
|
||||
}
|
||||
|
||||
|
||||
type ToastType
|
||||
= ErrorToast
|
||||
| SuccessToast
|
||||
| InfoToast
|
||||
| WarningToast
|
||||
|
||||
|
||||
type alias Toast =
|
||||
{ id : Int
|
||||
, message : String
|
||||
, toastType : ToastType
|
||||
, dismissible : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias Flags =
|
||||
{ token : Maybe String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias Schedule =
|
||||
{ id : Int
|
||||
, dayOfWeek : Int
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
, scheduleType : String
|
||||
, title : String
|
||||
}
|
||||
|
||||
|
||||
type alias User =
|
||||
{ id : Int
|
||||
, username : String
|
||||
, isAdmin : Bool
|
||||
, yearlyWorkHours : Float
|
||||
}
|
||||
|
||||
|
||||
type alias TimeEntry =
|
||||
{ id : Int
|
||||
, userId : Int
|
||||
, scheduleId : Int
|
||||
, date : String
|
||||
, entryType : String
|
||||
, username : String
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
}
|
||||
|
||||
|
||||
type alias SelectedEntry =
|
||||
{ scheduleId : Int
|
||||
, dayOfWeek : Int
|
||||
}
|
||||
|
||||
|
||||
type alias NewSchedule =
|
||||
{ dayOfWeek : String
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
, scheduleType : String
|
||||
, title : String
|
||||
}
|
||||
|
||||
|
||||
type alias NewUser =
|
||||
{ username : String
|
||||
, password : String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias WeekDates =
|
||||
{ year : Int
|
||||
, week : Int
|
||||
, dates : List ( String, String )
|
||||
, range : String
|
||||
}
|
||||
|
||||
|
||||
type alias WeeklySummary =
|
||||
{ userId : Int
|
||||
, username : String
|
||||
, year : Int
|
||||
, week : Int
|
||||
, totalHours : Float
|
||||
, targetHours : Float
|
||||
, remainingHours : Float
|
||||
}
|
||||
|
||||
|
||||
type alias EditingTimeEntry =
|
||||
{ entryId : Int
|
||||
, date : String
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
, entryType : String
|
||||
}
|
||||
|
||||
|
||||
type alias WeeklyHours =
|
||||
{ userId : Int
|
||||
, username : String
|
||||
, year : Int
|
||||
, week : Int
|
||||
, totalHours : Float
|
||||
, targetHours : Float
|
||||
, remainingHours : Float
|
||||
}
|
||||
|
||||
|
||||
type alias YearlyHoursSummary =
|
||||
{ userId : Int
|
||||
, username : String
|
||||
, year : Int
|
||||
, week : Int
|
||||
, totalHours : Float
|
||||
, yearlyTarget : Float
|
||||
, yearlyActual : Float
|
||||
, weeklyTarget : Float
|
||||
, remainingYearly : Float
|
||||
}
|
||||
|
||||
|
||||
type alias AdminManualEntry =
|
||||
{ selectedUserId : Maybe Int
|
||||
, date : String
|
||||
, hours : String
|
||||
, entryType : String
|
||||
}
|
||||
|
||||
|
||||
type alias SchoolYear =
|
||||
{ id : Int
|
||||
, name : String
|
||||
, startDate : String
|
||||
, endDate : String
|
||||
, isActive : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias NewSchoolYear =
|
||||
{ name : String
|
||||
, startDate : String
|
||||
, endDate : String
|
||||
}
|
||||
133
frontend/src/Types/Msg.elm
Normal file
133
frontend/src/Types/Msg.elm
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
module Types.Msg exposing (Msg(..))
|
||||
|
||||
import Bytes exposing (Bytes)
|
||||
import Http
|
||||
import Time
|
||||
import Types.Api exposing (LoginResult)
|
||||
import Types.Model
|
||||
exposing
|
||||
( Schedule
|
||||
, SchoolYear
|
||||
, TimeEntry
|
||||
, ToastType(..)
|
||||
, User
|
||||
, WeekDates
|
||||
, WeeklyHours
|
||||
, WeeklySummary
|
||||
, YearlyHoursSummary
|
||||
)
|
||||
import Types.Page exposing (AdminTab)
|
||||
|
||||
|
||||
type Msg
|
||||
= UpdateUsername String
|
||||
| UpdatePassword String
|
||||
| Login
|
||||
| LoginResponse (Result Http.Error LoginResult)
|
||||
| Logout
|
||||
| SetTime Time.Posix
|
||||
| FetchSchedules
|
||||
| SchedulesReceived (Result Http.Error (List Schedule))
|
||||
| ToggleScheduleSelection Int Int
|
||||
| SaveTimeEntries
|
||||
| TimeEntriesSaved (Result Http.Error ())
|
||||
| PreviousWeek
|
||||
| NextWeek
|
||||
| EnableEditMode
|
||||
| DisableEditMode
|
||||
| DeleteWeekEntries
|
||||
| WeekEntriesDeleted (Result Http.Error ())
|
||||
| SwitchTab AdminTab
|
||||
| UpdateNewScheduleDay String
|
||||
| UpdateNewScheduleStart String
|
||||
| UpdateNewScheduleEnd String
|
||||
| UpdateNewScheduleType String
|
||||
| UpdateNewScheduleTitle String
|
||||
| CreateSchedule
|
||||
| ScheduleCreated (Result Http.Error ())
|
||||
| DeleteSchedule Int
|
||||
| ScheduleDeleted (Result Http.Error ())
|
||||
| UpdateNewUsername String
|
||||
| UpdateNewPassword String
|
||||
| UpdateNewUserAdmin Bool
|
||||
| CreateUser
|
||||
| UserCreated (Result Http.Error ())
|
||||
| DeleteUser Int
|
||||
| UserDeleted (Result Http.Error ())
|
||||
| FetchUsers
|
||||
| UsersReceived (Result Http.Error (List User))
|
||||
| FetchMyTimeEntries
|
||||
| MyTimeEntriesReceived (Result Http.Error (List TimeEntry))
|
||||
| FetchAllTimeEntries
|
||||
| AllTimeEntriesReceived (Result Http.Error (List TimeEntry))
|
||||
| FetchWeeklyHours
|
||||
| WeeklyHoursReceived (Result Http.Error (List WeeklyHours))
|
||||
| FetchYearlyHoursSummary
|
||||
| YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary))
|
||||
| FetchWeekDates
|
||||
| WeekDatesReceived (Result Http.Error WeekDates)
|
||||
| CheckWeekHasEntries
|
||||
| WeekHasEntriesReceived (Result Http.Error Bool)
|
||||
| MyWeeklySummaryReceived (Result Http.Error WeeklySummary)
|
||||
| EditTimeEntry Int
|
||||
| CancelEditTimeEntry
|
||||
| UpdateEditTimeEntryDate String
|
||||
| UpdateEditTimeEntryStartTime String
|
||||
| UpdateEditTimeEntryEndTime String
|
||||
| UpdateEditTimeEntryType String
|
||||
| SaveEditTimeEntry
|
||||
| TimeEntrySaved (Result Http.Error ())
|
||||
| TimeEntryDeleted (Result Http.Error ())
|
||||
| EditUserWorkHours Int
|
||||
| CancelEditUserWorkHours
|
||||
| UpdateEditUserWorkHours String
|
||||
| SaveUserWorkHours
|
||||
| UserWorkHoursSaved (Result Http.Error ())
|
||||
| ResetUserPassword Int
|
||||
| CancelResetPassword
|
||||
| UpdateResetPasswordNew String
|
||||
| SaveResetPassword
|
||||
| ResetPasswordSaved (Result Http.Error ())
|
||||
| ConfirmDeleteTimeEntry Int
|
||||
| ConfirmDeleteUser Int
|
||||
| DeleteConfirmed Bool
|
||||
| StartEditingTimeEntry Int TimeEntry
|
||||
| CancelEditingTimeEntry
|
||||
| UpdateEditingTimeEntryDate String
|
||||
| UpdateEditingTimeEntryStartTime String
|
||||
| UpdateEditingTimeEntryEndTime String
|
||||
| UpdateEditingTimeEntryType String
|
||||
| SaveEditingTimeEntry
|
||||
| SelectUserForManagement Int
|
||||
| UpdateUserWorkHours String
|
||||
| UpdateUserPassword String
|
||||
| SaveUserPassword
|
||||
| UserPasswordSaved (Result Http.Error ())
|
||||
| ToggleMobileMenu
|
||||
| CloseMobileMenu
|
||||
| SelectUserForManualEntry Int
|
||||
| UpdateManualEntryDate String
|
||||
| UpdateManualEntryHours String
|
||||
| UpdateManualEntryType String
|
||||
| SaveAdminTimeEntry
|
||||
| AdminTimeEntrySaved (Result Http.Error ())
|
||||
| FetchMyInfo
|
||||
| MyInfoReceived (Result Http.Error User)
|
||||
| FetchSchoolYears
|
||||
| SchoolYearsReceived (Result Http.Error (List SchoolYear))
|
||||
| FetchActiveSchoolYear
|
||||
| ActiveSchoolYearReceived (Result Http.Error SchoolYear)
|
||||
| UpdateNewSchoolYearName String
|
||||
| UpdateNewSchoolYearStart String
|
||||
| UpdateNewSchoolYearEnd String
|
||||
| CreateSchoolYear
|
||||
| SchoolYearCreated (Result Http.Error ())
|
||||
| ActivateSchoolYear Int
|
||||
| SchoolYearActivated (Result Http.Error ())
|
||||
| DeleteSchoolYear Int
|
||||
| SchoolYearDeleted (Result Http.Error ())
|
||||
| DownloadYearlySummaryPDF
|
||||
| YearlySummaryPDFReceived (Result Http.Error Bytes)
|
||||
| ShowToast String ToastType
|
||||
| DismissToast Int
|
||||
| AutoDismissToast Int
|
||||
17
frontend/src/Types/Page.elm
Normal file
17
frontend/src/Types/Page.elm
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module Types.Page exposing
|
||||
( AdminTab(..)
|
||||
, Page(..)
|
||||
)
|
||||
|
||||
|
||||
type Page
|
||||
= LoginPage
|
||||
| UserDashboard
|
||||
| AdminDashboard
|
||||
|
||||
|
||||
type AdminTab
|
||||
= ScheduleTab
|
||||
| UsersTab
|
||||
| TimeEntriesTab
|
||||
| SchoolYearsTab
|
||||
115
frontend/src/Update/AuthUpdate.elm
Normal file
115
frontend/src/Update/AuthUpdate.elm
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
module Update.AuthUpdate exposing
|
||||
( handleLogin
|
||||
, handleLoginResponse
|
||||
, handleLogout
|
||||
)
|
||||
|
||||
import Api.Auth
|
||||
import Api.Schedule
|
||||
import Api.SchoolYear
|
||||
import Api.TimeEntry
|
||||
import Api.User
|
||||
import Http
|
||||
import Json.Encode as Encode
|
||||
import Task
|
||||
import Types.Model exposing (Model, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (Page(..))
|
||||
import Utils.DateUtils exposing (getISOWeekFromPosix)
|
||||
import Utils.Ports exposing (removeToken, saveToken)
|
||||
|
||||
|
||||
handleLogin : Model -> ( Model, Cmd Msg )
|
||||
handleLogin model =
|
||||
if model.isProcessing then
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password )
|
||||
|
||||
|
||||
handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg )
|
||||
handleLoginResponse result model =
|
||||
case result of
|
||||
Ok loginResult ->
|
||||
let
|
||||
newPage =
|
||||
if loginResult.isAdmin then
|
||||
AdminDashboard
|
||||
|
||||
else
|
||||
UserDashboard
|
||||
|
||||
( year, week ) =
|
||||
getISOWeekFromPosix model.currentTime
|
||||
|
||||
tokenData =
|
||||
Encode.object
|
||||
[ ( "token", Encode.string loginResult.token )
|
||||
, ( "isAdmin", Encode.bool loginResult.isAdmin )
|
||||
]
|
||||
in
|
||||
( { model
|
||||
| token = Just loginResult.token
|
||||
, username = loginResult.username
|
||||
, isAdmin = loginResult.isAdmin
|
||||
, page = newPage
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ saveToken tokenData
|
||||
, Api.Schedule.fetchSchedules (Just loginResult.token)
|
||||
, Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ())
|
||||
, if not loginResult.isAdmin then
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
|
||||
, Api.TimeEntry.fetchWeekDates loginResult.token year week
|
||||
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
|
||||
, Api.User.fetchMyInfo loginResult.token
|
||||
]
|
||||
|
||||
else
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
|
||||
, Api.TimeEntry.fetchWeekDates loginResult.token year week
|
||||
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
Err err ->
|
||||
let
|
||||
errorMsg =
|
||||
case err of
|
||||
Http.BadStatus 401 ->
|
||||
"Benutzername oder Passwort ungültig"
|
||||
|
||||
Http.Timeout ->
|
||||
"Zeitüberschreitung - bitte erneut versuchen"
|
||||
|
||||
Http.NetworkError ->
|
||||
"Netzwerkfehler - bitte Verbindung prüfen"
|
||||
|
||||
_ ->
|
||||
"Anmeldung fehlgeschlagen"
|
||||
in
|
||||
( { model | isProcessing = False }
|
||||
, Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ())
|
||||
)
|
||||
|
||||
|
||||
handleLogout : Model -> ( Model, Cmd Msg )
|
||||
handleLogout model =
|
||||
( { model
|
||||
| page = LoginPage
|
||||
, token = Nothing
|
||||
, isAdmin = False
|
||||
, username = ""
|
||||
, password = ""
|
||||
, isProcessing = False
|
||||
}
|
||||
, removeToken ()
|
||||
)
|
||||
244
frontend/src/Update/ScheduleUpdate.elm
Normal file
244
frontend/src/Update/ScheduleUpdate.elm
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
module Update.ScheduleUpdate exposing
|
||||
( handleCreateSchedule
|
||||
, handleDeleteSchedule
|
||||
, handleDeleteWeekEntries
|
||||
, handleDisableEditMode
|
||||
, handleEnableEditMode
|
||||
, handleSaveTimeEntries
|
||||
, handleScheduleCreated
|
||||
, handleScheduleDeleted
|
||||
, handleSchedulesReceived
|
||||
, handleTimeEntriesSaved
|
||||
, handleToggleScheduleSelection
|
||||
, handleWeekEntriesDeleted
|
||||
)
|
||||
|
||||
import Api.Schedule
|
||||
import Api.TimeEntry
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate)
|
||||
|
||||
|
||||
handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg )
|
||||
handleToggleScheduleSelection scheduleId dayOfWeek model =
|
||||
let
|
||||
entry =
|
||||
{ scheduleId = scheduleId, dayOfWeek = dayOfWeek }
|
||||
|
||||
newSelected =
|
||||
if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then
|
||||
List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries
|
||||
|
||||
else
|
||||
entry :: model.selectedEntries
|
||||
in
|
||||
( { model | selectedEntries = newSelected }, Cmd.none )
|
||||
|
||||
|
||||
handleSaveTimeEntries : Model -> ( Model, Cmd Msg )
|
||||
handleSaveTimeEntries model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleTimeEntriesSaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| error = Nothing
|
||||
, weekEditMode = False
|
||||
, hasEntriesForCurrentWeek = True
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries token
|
||||
, Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEnableEditMode : Model -> ( Model, Cmd Msg )
|
||||
handleEnableEditMode model =
|
||||
let
|
||||
currentWeekEntries =
|
||||
List.filter
|
||||
(\e ->
|
||||
let
|
||||
( entryYear, entryWeek ) =
|
||||
getYearWeekFromDate e.date
|
||||
in
|
||||
entryWeek == model.currentWeek && entryYear == model.currentYear
|
||||
)
|
||||
model.timeEntries
|
||||
|
||||
preSelectedEntries =
|
||||
List.map
|
||||
(\entry ->
|
||||
let
|
||||
parts =
|
||||
String.split "-" entry.date
|
||||
|
||||
year =
|
||||
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
|
||||
|
||||
month =
|
||||
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
|
||||
day =
|
||||
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
|
||||
dayOfWeek =
|
||||
getDayOfWeek year month day
|
||||
in
|
||||
{ scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek }
|
||||
)
|
||||
currentWeekEntries
|
||||
in
|
||||
( { model
|
||||
| weekEditMode = True
|
||||
, selectedEntries = preSelectedEntries
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
handleDisableEditMode : Model -> ( Model, Cmd Msg )
|
||||
handleDisableEditMode model =
|
||||
( { model | weekEditMode = False }, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteWeekEntries : Model -> ( Model, Cmd Msg )
|
||||
handleDeleteWeekEntries model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleWeekEntriesDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| weekEditMode = True
|
||||
, selectedEntries = []
|
||||
, hasEntriesForCurrentWeek = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries token
|
||||
, Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleCreateSchedule : Model -> ( Model, Cmd Msg )
|
||||
handleCreateSchedule model =
|
||||
if
|
||||
String.isEmpty model.newSchedule.dayOfWeek
|
||||
|| String.isEmpty model.newSchedule.startTime
|
||||
|| String.isEmpty model.newSchedule.endTime
|
||||
then
|
||||
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
|
||||
|
||||
else
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleScheduleCreated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
let
|
||||
emptySchedule =
|
||||
NewSchedule "" "" "" "lesson" ""
|
||||
in
|
||||
( { model
|
||||
| newSchedule = emptySchedule
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.Schedule.fetchSchedules model.token
|
||||
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleDeleteSchedule scheduleId model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.Schedule.deleteSchedule token scheduleId )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleScheduleDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Cmd.batch
|
||||
[ Api.Schedule.fetchSchedules (Just token)
|
||||
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg )
|
||||
handleSchedulesReceived result model =
|
||||
case result of
|
||||
Ok schedules ->
|
||||
( { model | schedules = schedules }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
139
frontend/src/Update/SchoolYearUpdate.elm
Normal file
139
frontend/src/Update/SchoolYearUpdate.elm
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
module Update.SchoolYearUpdate exposing
|
||||
( handleActivateSchoolYear
|
||||
, handleActiveSchoolYearReceived
|
||||
, handleCreateSchoolYear
|
||||
, handleDeleteSchoolYear
|
||||
, handleSchoolYearActivated
|
||||
, handleSchoolYearCreated
|
||||
, handleSchoolYearDeleted
|
||||
, handleSchoolYearsReceived
|
||||
)
|
||||
|
||||
import Api.SchoolYear
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
handleCreateSchoolYear : Model -> ( Model, Cmd Msg )
|
||||
handleCreateSchoolYear model =
|
||||
if
|
||||
String.isEmpty model.newSchoolYear.name
|
||||
|| String.isEmpty model.newSchoolYear.startDate
|
||||
|| String.isEmpty model.newSchoolYear.endDate
|
||||
then
|
||||
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
|
||||
|
||||
else
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearCreated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| newSchoolYear = NewSchoolYear "" "" ""
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
|
||||
handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleActivateSchoolYear id model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.activateSchoolYear token id )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearActivated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Api.SchoolYear.fetchActiveSchoolYear token
|
||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleDeleteSchoolYear id model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.deleteSchoolYear token id )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearsReceived result model =
|
||||
case result of
|
||||
Ok years ->
|
||||
( { model | schoolYears = years }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg )
|
||||
handleActiveSchoolYearReceived result model =
|
||||
case result of
|
||||
Ok year ->
|
||||
( { model | activeSchoolYear = Just year }, Cmd.none )
|
||||
|
||||
Err _ ->
|
||||
( { model | activeSchoolYear = Nothing }, Cmd.none )
|
||||
189
frontend/src/Update/TimeEntryUpdate.elm
Normal file
189
frontend/src/Update/TimeEntryUpdate.elm
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
module Update.TimeEntryUpdate exposing
|
||||
( handleAdminTimeEntrySaved
|
||||
, handleAllTimeEntriesReceived
|
||||
, handleConfirmDeleteTimeEntry
|
||||
, handleEditTimeEntry
|
||||
, handleMyTimeEntriesReceived
|
||||
, handleSaveAdminTimeEntry
|
||||
, handleSaveEditTimeEntry
|
||||
, handleTimeEntryDeleted
|
||||
, handleTimeEntrySaved
|
||||
, handleYearlyHoursSummaryReceived
|
||||
)
|
||||
|
||||
import Api.TimeEntry
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.DateUtils exposing (getYearWeekFromDate)
|
||||
import Utils.Ports exposing (confirmDelete)
|
||||
|
||||
|
||||
handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
|
||||
handleMyTimeEntriesReceived result model =
|
||||
case result of
|
||||
Ok entries ->
|
||||
let
|
||||
hasEntries =
|
||||
List.any
|
||||
(\e ->
|
||||
let
|
||||
( entryYear, entryWeek ) =
|
||||
getYearWeekFromDate e.date
|
||||
in
|
||||
entryWeek == model.currentWeek && entryYear == model.currentYear
|
||||
)
|
||||
entries
|
||||
in
|
||||
( { model
|
||||
| timeEntries = entries
|
||||
, hasEntriesForCurrentWeek = hasEntries
|
||||
, weekEditMode = False
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
|
||||
handleAllTimeEntriesReceived result model =
|
||||
case result of
|
||||
Ok entries ->
|
||||
( { model | timeEntries = entries }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleEditTimeEntry entryId model =
|
||||
case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of
|
||||
Just entry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Just entryId
|
||||
, editingTimeEntry =
|
||||
{ entryId = entryId
|
||||
, date = entry.date
|
||||
, startTime = entry.startTime
|
||||
, endTime = entry.endTime
|
||||
, entryType = entry.entryType
|
||||
}
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg )
|
||||
handleSaveEditTimeEntry model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleTimeEntrySaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, pendingDeleteId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleTimeEntryDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
|
||||
, pendingDeleteId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
||||
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
||||
|
||||
|
||||
handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleConfirmDeleteTimeEntry entryId model =
|
||||
( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" )
|
||||
|
||||
|
||||
handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg )
|
||||
handleSaveAdminTimeEntry model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleAdminTimeEntrySaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
||||
, Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
|
||||
handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg )
|
||||
handleYearlyHoursSummaryReceived result model =
|
||||
case result of
|
||||
Ok summary ->
|
||||
( { model | yearlyHoursSummary = summary }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
811
frontend/src/Update/Update.elm
Normal file
811
frontend/src/Update/Update.elm
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
module Update.Update exposing (update)
|
||||
|
||||
import Api.Schedule
|
||||
import Api.SchoolYear
|
||||
import Api.TimeEntry
|
||||
import Api.User
|
||||
import File.Download
|
||||
import Process
|
||||
import Task
|
||||
import Time
|
||||
import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (AdminTab(..), Page(..))
|
||||
import Update.AuthUpdate as Auth
|
||||
import Update.ScheduleUpdate as Schedule
|
||||
import Update.SchoolYearUpdate as SchoolYear
|
||||
import Update.TimeEntryUpdate as TimeEntry
|
||||
import Update.UserUpdate as User
|
||||
import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek)
|
||||
import Utils.Ports
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
-- Mobile Menu
|
||||
ToggleMobileMenu ->
|
||||
( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none )
|
||||
|
||||
CloseMobileMenu ->
|
||||
( { model | mobileMenuOpen = False }, Cmd.none )
|
||||
|
||||
-- Auth
|
||||
UpdateUsername username ->
|
||||
( { model | username = username }, Cmd.none )
|
||||
|
||||
UpdatePassword password ->
|
||||
( { model | password = password }, Cmd.none )
|
||||
|
||||
Login ->
|
||||
Auth.handleLogin model
|
||||
|
||||
LoginResponse result ->
|
||||
Auth.handleLoginResponse result model
|
||||
|
||||
Logout ->
|
||||
Auth.handleLogout model
|
||||
|
||||
-- Time
|
||||
SetTime time ->
|
||||
let
|
||||
( year, week ) =
|
||||
getISOWeekFromPosix time
|
||||
|
||||
cmds =
|
||||
case model.token of
|
||||
Just token ->
|
||||
if model.page == UserDashboard || model.page == LoginPage then
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.checkWeekHasEntries token year week
|
||||
, Api.TimeEntry.fetchWeekDates token year week
|
||||
, Api.TimeEntry.fetchMyTimeEntries token
|
||||
]
|
||||
|
||||
else
|
||||
Cmd.none
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
in
|
||||
( { model
|
||||
| currentTime = time
|
||||
, currentWeek = week
|
||||
, currentYear = year
|
||||
}
|
||||
, cmds
|
||||
)
|
||||
|
||||
-- Schedules
|
||||
FetchSchedules ->
|
||||
( model, Api.Schedule.fetchSchedules model.token )
|
||||
|
||||
SchedulesReceived result ->
|
||||
Schedule.handleSchedulesReceived result model
|
||||
|
||||
ToggleScheduleSelection scheduleId dayOfWeek ->
|
||||
Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model
|
||||
|
||||
SaveTimeEntries ->
|
||||
Schedule.handleSaveTimeEntries model
|
||||
|
||||
TimeEntriesSaved result ->
|
||||
Schedule.handleTimeEntriesSaved result model
|
||||
|
||||
EnableEditMode ->
|
||||
Schedule.handleEnableEditMode model
|
||||
|
||||
DisableEditMode ->
|
||||
Schedule.handleDisableEditMode model
|
||||
|
||||
DeleteWeekEntries ->
|
||||
Schedule.handleDeleteWeekEntries model
|
||||
|
||||
WeekEntriesDeleted result ->
|
||||
Schedule.handleWeekEntriesDeleted result model
|
||||
|
||||
CreateSchedule ->
|
||||
Schedule.handleCreateSchedule model
|
||||
|
||||
ScheduleCreated result ->
|
||||
Schedule.handleScheduleCreated result model
|
||||
|
||||
DeleteSchedule scheduleId ->
|
||||
Schedule.handleDeleteSchedule scheduleId model
|
||||
|
||||
ScheduleDeleted result ->
|
||||
Schedule.handleScheduleDeleted result model
|
||||
|
||||
-- Week Navigation
|
||||
PreviousWeek ->
|
||||
let
|
||||
( newYear, newWeek ) =
|
||||
previousWeek model.currentYear model.currentWeek
|
||||
in
|
||||
( { model
|
||||
| currentWeek = newWeek
|
||||
, currentYear = newYear
|
||||
, selectedEntries = []
|
||||
, weekEditMode = False
|
||||
}
|
||||
, case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
|
||||
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
)
|
||||
|
||||
NextWeek ->
|
||||
let
|
||||
( newYear, newWeek ) =
|
||||
nextWeek model.currentYear model.currentWeek
|
||||
in
|
||||
( { model
|
||||
| currentWeek = newWeek
|
||||
, currentYear = newYear
|
||||
, selectedEntries = []
|
||||
, weekEditMode = False
|
||||
}
|
||||
, case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
|
||||
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
)
|
||||
|
||||
FetchWeekDates ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
WeekDatesReceived result ->
|
||||
case result of
|
||||
Ok weekDates ->
|
||||
( { model | weekDates = Just weekDates }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
CheckWeekHasEntries ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
WeekHasEntriesReceived result ->
|
||||
case result of
|
||||
Ok hasEntries ->
|
||||
( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
-- Admin Tabs
|
||||
SwitchTab tab ->
|
||||
let
|
||||
cmd =
|
||||
case tab of
|
||||
UsersTab ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
Api.User.fetchUsers token
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
TimeEntriesTab ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
SchoolYearsTab ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Api.SchoolYear.fetchActiveSchoolYear token
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
_ ->
|
||||
Cmd.none
|
||||
in
|
||||
( { model | activeTab = tab, mobileMenuOpen = False }, cmd )
|
||||
|
||||
-- Schedule Form
|
||||
UpdateNewScheduleDay day ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | dayOfWeek = day }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleStart time ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | startTime = time }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleEnd time ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | endTime = time }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleType scheduleType ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | scheduleType = scheduleType }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleTitle title ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | title = title }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
-- Users
|
||||
UpdateNewUsername username ->
|
||||
let
|
||||
oldUser =
|
||||
model.newUser
|
||||
|
||||
newUser =
|
||||
{ oldUser | username = username }
|
||||
in
|
||||
( { model | newUser = newUser }, Cmd.none )
|
||||
|
||||
UpdateNewPassword password ->
|
||||
let
|
||||
oldUser =
|
||||
model.newUser
|
||||
|
||||
newUser =
|
||||
{ oldUser | password = password }
|
||||
in
|
||||
( { model | newUser = newUser }, Cmd.none )
|
||||
|
||||
UpdateNewUserAdmin isAdmin ->
|
||||
let
|
||||
oldUser =
|
||||
model.newUser
|
||||
|
||||
newUser =
|
||||
{ oldUser | isAdmin = isAdmin }
|
||||
in
|
||||
( { model | newUser = newUser }, Cmd.none )
|
||||
|
||||
CreateUser ->
|
||||
User.handleCreateUser model
|
||||
|
||||
UserCreated result ->
|
||||
User.handleUserCreated result model
|
||||
|
||||
DeleteUser userId ->
|
||||
User.handleDeleteUser userId model
|
||||
|
||||
UserDeleted result ->
|
||||
User.handleUserDeleted result model
|
||||
|
||||
FetchUsers ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.fetchUsers token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
UsersReceived result ->
|
||||
User.handleUsersReceived result model
|
||||
|
||||
EditUserWorkHours userId ->
|
||||
User.handleEditUserWorkHours userId model
|
||||
|
||||
CancelEditUserWorkHours ->
|
||||
( { model
|
||||
| editingUserId = Nothing
|
||||
, editingUserWorkHours = ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateEditUserWorkHours hours ->
|
||||
( { model | editingUserWorkHours = hours }, Cmd.none )
|
||||
|
||||
SaveUserWorkHours ->
|
||||
User.handleSaveUserWorkHours model
|
||||
|
||||
UserWorkHoursSaved result ->
|
||||
User.handleUserWorkHoursSaved result model
|
||||
|
||||
ResetUserPassword userId ->
|
||||
User.handleResetUserPassword userId model
|
||||
|
||||
CancelResetPassword ->
|
||||
( { model
|
||||
| resetPasswordUserId = Nothing
|
||||
, resetPasswordNew = ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateResetPasswordNew password ->
|
||||
( { model | resetPasswordNew = password }, Cmd.none )
|
||||
|
||||
SaveResetPassword ->
|
||||
User.handleSaveResetPassword model
|
||||
|
||||
ResetPasswordSaved result ->
|
||||
User.handleResetPasswordSaved result model
|
||||
|
||||
UpdateUserWorkHours input ->
|
||||
( { model | userWorkHoursInput = input }, Cmd.none )
|
||||
|
||||
UpdateUserPassword input ->
|
||||
( { model | userPasswordInput = input }, Cmd.none )
|
||||
|
||||
SaveUserPassword ->
|
||||
case ( model.token, model.selectedUserId ) of
|
||||
( Just token, Just userId ) ->
|
||||
if String.length model.userPasswordInput > 0 then
|
||||
( model, Api.User.resetUserPassword token userId model.userPasswordInput )
|
||||
|
||||
else
|
||||
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
|
||||
|
||||
_ ->
|
||||
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
|
||||
|
||||
UserPasswordSaved result ->
|
||||
case result of
|
||||
Ok _ ->
|
||||
( { model
|
||||
| userPasswordInput = ""
|
||||
, selectedUserId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ())
|
||||
)
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SelectUserForManagement userId ->
|
||||
( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none )
|
||||
|
||||
-- Time Entries
|
||||
FetchMyTimeEntries ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchMyTimeEntries token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MyTimeEntriesReceived result ->
|
||||
TimeEntry.handleMyTimeEntriesReceived result model
|
||||
|
||||
FetchAllTimeEntries ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchAllTimeEntries token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
AllTimeEntriesReceived result ->
|
||||
TimeEntry.handleAllTimeEntriesReceived result model
|
||||
|
||||
EditTimeEntry entryId ->
|
||||
TimeEntry.handleEditTimeEntry entryId model
|
||||
|
||||
CancelEditTimeEntry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateEditTimeEntryDate date ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | date = date }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditTimeEntryStartTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | startTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditTimeEntryEndTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | endTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditTimeEntryType entryType ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | entryType = entryType }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
SaveEditTimeEntry ->
|
||||
TimeEntry.handleSaveEditTimeEntry model
|
||||
|
||||
TimeEntrySaved result ->
|
||||
TimeEntry.handleTimeEntrySaved result model
|
||||
|
||||
TimeEntryDeleted result ->
|
||||
TimeEntry.handleTimeEntryDeleted result model
|
||||
|
||||
ConfirmDeleteTimeEntry entryId ->
|
||||
TimeEntry.handleConfirmDeleteTimeEntry entryId model
|
||||
|
||||
StartEditingTimeEntry entryId entry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Just entryId
|
||||
, editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
CancelEditingTimeEntry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateEditingTimeEntryDate date ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | date = date }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditingTimeEntryStartTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | startTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditingTimeEntryEndTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | endTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditingTimeEntryType entryType ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | entryType = entryType }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
SaveEditingTimeEntry ->
|
||||
case ( model.token, model.editingTimeEntryId ) of
|
||||
( Just token, Just entryId ) ->
|
||||
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
-- Weekly Hours
|
||||
FetchWeeklyHours ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
WeeklyHoursReceived result ->
|
||||
case result of
|
||||
Ok hours ->
|
||||
( { model | weeklyHours = hours }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MyWeeklySummaryReceived result ->
|
||||
case result of
|
||||
Ok summary ->
|
||||
( { model | userWeeklySummary = Just summary }, Cmd.none )
|
||||
|
||||
Err _ ->
|
||||
( { model | userWeeklySummary = Nothing }, Cmd.none )
|
||||
|
||||
-- Yearly Hours
|
||||
FetchYearlyHoursSummary ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchYearlyHoursSummary token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
YearlyHoursSummaryReceived result ->
|
||||
TimeEntry.handleYearlyHoursSummaryReceived result model
|
||||
|
||||
-- Admin Manual Entry
|
||||
SelectUserForManualEntry userId ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none )
|
||||
|
||||
UpdateManualEntryDate date ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | date = date } }, Cmd.none )
|
||||
|
||||
UpdateManualEntryHours hours ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none )
|
||||
|
||||
UpdateManualEntryType entryType ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none )
|
||||
|
||||
SaveAdminTimeEntry ->
|
||||
TimeEntry.handleSaveAdminTimeEntry model
|
||||
|
||||
AdminTimeEntrySaved result ->
|
||||
TimeEntry.handleAdminTimeEntrySaved result model
|
||||
|
||||
-- My Info
|
||||
FetchMyInfo ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.fetchMyInfo token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MyInfoReceived result ->
|
||||
case result of
|
||||
Ok user ->
|
||||
( { model | users = [ user ] }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
-- School Years
|
||||
FetchSchoolYears ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.fetchSchoolYears token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SchoolYearsReceived result ->
|
||||
SchoolYear.handleSchoolYearsReceived result model
|
||||
|
||||
FetchActiveSchoolYear ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.fetchActiveSchoolYear token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ActiveSchoolYearReceived result ->
|
||||
SchoolYear.handleActiveSchoolYearReceived result model
|
||||
|
||||
UpdateNewSchoolYearName name ->
|
||||
let
|
||||
old =
|
||||
model.newSchoolYear
|
||||
|
||||
new =
|
||||
{ old | name = name }
|
||||
in
|
||||
( { model | newSchoolYear = new }, Cmd.none )
|
||||
|
||||
UpdateNewSchoolYearStart date ->
|
||||
let
|
||||
old =
|
||||
model.newSchoolYear
|
||||
|
||||
new =
|
||||
{ old | startDate = date }
|
||||
in
|
||||
( { model | newSchoolYear = new }, Cmd.none )
|
||||
|
||||
UpdateNewSchoolYearEnd date ->
|
||||
let
|
||||
old =
|
||||
model.newSchoolYear
|
||||
|
||||
new =
|
||||
{ old | endDate = date }
|
||||
in
|
||||
( { model | newSchoolYear = new }, Cmd.none )
|
||||
|
||||
CreateSchoolYear ->
|
||||
SchoolYear.handleCreateSchoolYear model
|
||||
|
||||
SchoolYearCreated result ->
|
||||
SchoolYear.handleSchoolYearCreated result model
|
||||
|
||||
ActivateSchoolYear id ->
|
||||
SchoolYear.handleActivateSchoolYear id model
|
||||
|
||||
SchoolYearActivated result ->
|
||||
SchoolYear.handleSchoolYearActivated result model
|
||||
|
||||
DeleteSchoolYear id ->
|
||||
SchoolYear.handleDeleteSchoolYear id model
|
||||
|
||||
SchoolYearDeleted result ->
|
||||
SchoolYear.handleSchoolYearDeleted result model
|
||||
|
||||
-- PDF Download
|
||||
DownloadYearlySummaryPDF ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
YearlySummaryPDFReceived result ->
|
||||
case result of
|
||||
Ok pdfBytes ->
|
||||
let
|
||||
filename =
|
||||
"Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf"
|
||||
in
|
||||
( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
-- Delete Confirmation
|
||||
ConfirmDeleteUser userId ->
|
||||
( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" )
|
||||
|
||||
DeleteConfirmed confirmed ->
|
||||
if confirmed then
|
||||
case ( model.token, model.pendingDeleteId ) of
|
||||
( Just token, Just id ) ->
|
||||
let
|
||||
isTimeEntry =
|
||||
List.any (\e -> e.id == id) model.timeEntries
|
||||
in
|
||||
if isTimeEntry then
|
||||
( model, Api.TimeEntry.deleteTimeEntry token id )
|
||||
|
||||
else
|
||||
( model, Api.User.deleteUser token id )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
||||
|
||||
-- Toasts
|
||||
ShowToast message toastType ->
|
||||
let
|
||||
newToast =
|
||||
{ id = model.nextToastId
|
||||
, message = message
|
||||
, toastType = toastType
|
||||
, dismissible = True
|
||||
}
|
||||
|
||||
dismissDelay =
|
||||
case toastType of
|
||||
ErrorToast ->
|
||||
8000
|
||||
|
||||
SuccessToast ->
|
||||
5000
|
||||
|
||||
InfoToast ->
|
||||
5000
|
||||
|
||||
WarningToast ->
|
||||
6000
|
||||
in
|
||||
( { model
|
||||
| toasts = model.toasts ++ [ newToast ]
|
||||
, nextToastId = model.nextToastId + 1
|
||||
}
|
||||
, Task.perform (\_ -> AutoDismissToast newToast.id)
|
||||
(Process.sleep dismissDelay)
|
||||
)
|
||||
|
||||
DismissToast toastId ->
|
||||
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
AutoDismissToast toastId ->
|
||||
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
|
||||
, Cmd.none
|
||||
)
|
||||
196
frontend/src/Update/UserUpdate.elm
Normal file
196
frontend/src/Update/UserUpdate.elm
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
module Update.UserUpdate exposing
|
||||
( handleCreateUser
|
||||
, handleDeleteUser
|
||||
, handleEditUserWorkHours
|
||||
, handleResetPasswordSaved
|
||||
, handleResetUserPassword
|
||||
, handleSaveResetPassword
|
||||
, handleSaveUserWorkHours
|
||||
, handleUserCreated
|
||||
, handleUserDeleted
|
||||
, handleUserWorkHoursSaved
|
||||
, handleUsersReceived
|
||||
)
|
||||
|
||||
import Api.User
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (Model, NewUser, ToastType(..), User)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
handleCreateUser : Model -> ( Model, Cmd Msg )
|
||||
handleCreateUser model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.createUser token model.newUser )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleUserCreated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
let
|
||||
emptyUser =
|
||||
NewUser "" "" False
|
||||
in
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | newUser = emptyUser }
|
||||
, Cmd.batch
|
||||
[ Api.User.fetchUsers token
|
||||
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteUser : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleDeleteUser userId model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.deleteUser token userId )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleUserDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| pendingDeleteId = Nothing
|
||||
, error = Nothing
|
||||
, editingUserId = Nothing
|
||||
, resetPasswordUserId = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.User.fetchUsers token
|
||||
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
||||
|
||||
|
||||
handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg )
|
||||
handleUsersReceived result model =
|
||||
case result of
|
||||
Ok users ->
|
||||
( { model | users = users }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleEditUserWorkHours userId model =
|
||||
case List.filter (\u -> u.id == userId) model.users |> List.head of
|
||||
Just user ->
|
||||
( { model
|
||||
| editingUserId = Just userId
|
||||
, editingUserWorkHours = String.fromFloat user.yearlyWorkHours
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSaveUserWorkHours : Model -> ( Model, Cmd Msg )
|
||||
handleSaveUserWorkHours model =
|
||||
case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of
|
||||
( Just token, Just userId, Just hours ) ->
|
||||
( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) )
|
||||
|
||||
_ ->
|
||||
( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) )
|
||||
|
||||
|
||||
handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleUserWorkHoursSaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| editingUserWorkHours = ""
|
||||
, editingUserId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.User.fetchUsers token
|
||||
, Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleResetUserPassword userId model =
|
||||
( { model
|
||||
| resetPasswordUserId = Just userId
|
||||
, resetPasswordNew = ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
handleSaveResetPassword : Model -> ( Model, Cmd Msg )
|
||||
handleSaveResetPassword model =
|
||||
case model.resetPasswordUserId of
|
||||
Just userId ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.resetUserPassword token userId model.resetPasswordNew )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleResetPasswordSaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
( { model
|
||||
| resetPasswordUserId = Nothing
|
||||
, resetPasswordNew = ""
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ case model.token of
|
||||
Just token ->
|
||||
Api.User.fetchUsers token
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
338
frontend/src/Utils/DateUtils.elm
Normal file
338
frontend/src/Utils/DateUtils.elm
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
module Utils.DateUtils exposing
|
||||
( addDaysToDate
|
||||
, getDateForWeekDay
|
||||
, getDayOfWeek
|
||||
, getDayOfYear
|
||||
, getISOWeek
|
||||
, getISOWeekFromPosix
|
||||
, getWeekDateRange
|
||||
, getYearWeekFromDate
|
||||
, isLeapYear
|
||||
, monthToInt
|
||||
, nextWeek
|
||||
, previousWeek
|
||||
)
|
||||
|
||||
import Time
|
||||
|
||||
|
||||
getISOWeekFromPosix : Time.Posix -> ( Int, Int )
|
||||
getISOWeekFromPosix time =
|
||||
let
|
||||
year =
|
||||
Time.toYear Time.utc time
|
||||
|
||||
month =
|
||||
Time.toMonth Time.utc time |> monthToInt
|
||||
|
||||
day =
|
||||
Time.toDay Time.utc time
|
||||
in
|
||||
( year, getISOWeek year month day )
|
||||
|
||||
|
||||
monthToInt : Time.Month -> Int
|
||||
monthToInt month =
|
||||
case month of
|
||||
Time.Jan ->
|
||||
1
|
||||
|
||||
Time.Feb ->
|
||||
2
|
||||
|
||||
Time.Mar ->
|
||||
3
|
||||
|
||||
Time.Apr ->
|
||||
4
|
||||
|
||||
Time.May ->
|
||||
5
|
||||
|
||||
Time.Jun ->
|
||||
6
|
||||
|
||||
Time.Jul ->
|
||||
7
|
||||
|
||||
Time.Aug ->
|
||||
8
|
||||
|
||||
Time.Sep ->
|
||||
9
|
||||
|
||||
Time.Oct ->
|
||||
10
|
||||
|
||||
Time.Nov ->
|
||||
11
|
||||
|
||||
Time.Dec ->
|
||||
12
|
||||
|
||||
|
||||
getISOWeek : Int -> Int -> Int -> Int
|
||||
getISOWeek year month day =
|
||||
let
|
||||
dayOfYear =
|
||||
getDayOfYear year month day
|
||||
|
||||
jan4DayOfWeek =
|
||||
getDayOfWeek year 1 4
|
||||
|
||||
mondayOfWeek1DayOfYear =
|
||||
4 - jan4DayOfWeek
|
||||
|
||||
weekNum =
|
||||
((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1
|
||||
in
|
||||
if weekNum < 1 then
|
||||
52
|
||||
|
||||
else if weekNum > 52 then
|
||||
let
|
||||
dec31DayOfWeek =
|
||||
getDayOfWeek year 12 31
|
||||
|
||||
jan1DayOfWeek =
|
||||
getDayOfWeek year 1 1
|
||||
in
|
||||
if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then
|
||||
weekNum
|
||||
|
||||
else
|
||||
1
|
||||
|
||||
else
|
||||
weekNum
|
||||
|
||||
|
||||
getDayOfYear : Int -> Int -> Int -> Int
|
||||
getDayOfYear year month day =
|
||||
let
|
||||
daysInMonth =
|
||||
[ 31
|
||||
, if isLeapYear year then
|
||||
29
|
||||
|
||||
else
|
||||
28
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
]
|
||||
|
||||
daysBefore =
|
||||
List.take (month - 1) daysInMonth |> List.sum
|
||||
in
|
||||
daysBefore + day
|
||||
|
||||
|
||||
isLeapYear : Int -> Bool
|
||||
isLeapYear year =
|
||||
(modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0))
|
||||
|
||||
|
||||
getDayOfWeek : Int -> Int -> Int -> Int
|
||||
getDayOfWeek year month day =
|
||||
let
|
||||
adjustedMonth =
|
||||
if month < 3 then
|
||||
month + 12
|
||||
|
||||
else
|
||||
month
|
||||
|
||||
adjustedYear =
|
||||
if month < 3 then
|
||||
year - 1
|
||||
|
||||
else
|
||||
year
|
||||
|
||||
q =
|
||||
day
|
||||
|
||||
m =
|
||||
adjustedMonth
|
||||
|
||||
k =
|
||||
modBy 100 adjustedYear
|
||||
|
||||
j =
|
||||
adjustedYear // 100
|
||||
|
||||
h =
|
||||
(q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7
|
||||
in
|
||||
(h + 5) |> modBy 7
|
||||
|
||||
|
||||
getDateForWeekDay : Int -> Int -> Int -> String
|
||||
getDateForWeekDay year week dayOfWeek =
|
||||
let
|
||||
jan4DayOfWeek =
|
||||
getDayOfWeek year 1 4
|
||||
|
||||
mondayOfWeek1Date =
|
||||
4 - jan4DayOfWeek
|
||||
|
||||
targetDayOfYear =
|
||||
mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek
|
||||
|
||||
( finalYear, finalMonth, finalDay ) =
|
||||
if targetDayOfYear < 1 then
|
||||
addDaysToDate (year - 1) 12 31 targetDayOfYear
|
||||
|
||||
else
|
||||
addDaysToDate year 1 targetDayOfYear 0
|
||||
in
|
||||
String.fromInt finalYear
|
||||
++ "-"
|
||||
++ String.padLeft 2 '0' (String.fromInt finalMonth)
|
||||
++ "-"
|
||||
++ String.padLeft 2 '0' (String.fromInt finalDay)
|
||||
|
||||
|
||||
addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int )
|
||||
addDaysToDate startYear startMonth startDay daysToAdd =
|
||||
let
|
||||
daysInMonth m y =
|
||||
case m of
|
||||
1 ->
|
||||
31
|
||||
|
||||
2 ->
|
||||
if isLeapYear y then
|
||||
29
|
||||
|
||||
else
|
||||
28
|
||||
|
||||
3 ->
|
||||
31
|
||||
|
||||
4 ->
|
||||
30
|
||||
|
||||
5 ->
|
||||
31
|
||||
|
||||
6 ->
|
||||
30
|
||||
|
||||
7 ->
|
||||
31
|
||||
|
||||
8 ->
|
||||
31
|
||||
|
||||
9 ->
|
||||
30
|
||||
|
||||
10 ->
|
||||
31
|
||||
|
||||
11 ->
|
||||
30
|
||||
|
||||
12 ->
|
||||
31
|
||||
|
||||
_ ->
|
||||
0
|
||||
|
||||
helper y m d remaining =
|
||||
if remaining == 0 then
|
||||
( y, m, d )
|
||||
|
||||
else if remaining > 0 then
|
||||
let
|
||||
daysInCurrentMonth =
|
||||
daysInMonth m y
|
||||
|
||||
daysLeftInMonth =
|
||||
daysInCurrentMonth - d
|
||||
in
|
||||
if remaining <= daysLeftInMonth then
|
||||
( y, m, d + remaining )
|
||||
|
||||
else if m == 12 then
|
||||
helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1)
|
||||
|
||||
else
|
||||
helper y (m + 1) 1 (remaining - daysLeftInMonth - 1)
|
||||
|
||||
else if d + remaining >= 1 then
|
||||
( y, m, d + remaining )
|
||||
|
||||
else if m == 1 then
|
||||
let
|
||||
prevMonthDays =
|
||||
daysInMonth 12 (y - 1)
|
||||
in
|
||||
helper (y - 1) 12 prevMonthDays (remaining + d)
|
||||
|
||||
else
|
||||
let
|
||||
prevMonthDays =
|
||||
daysInMonth (m - 1) y
|
||||
in
|
||||
helper y (m - 1) prevMonthDays (remaining + d)
|
||||
in
|
||||
helper startYear startMonth startDay daysToAdd
|
||||
|
||||
|
||||
previousWeek : Int -> Int -> ( Int, Int )
|
||||
previousWeek year week =
|
||||
if week == 1 then
|
||||
( year - 1, 52 )
|
||||
|
||||
else
|
||||
( year, week - 1 )
|
||||
|
||||
|
||||
nextWeek : Int -> Int -> ( Int, Int )
|
||||
nextWeek year week =
|
||||
if week >= 52 then
|
||||
( year + 1, 1 )
|
||||
|
||||
else
|
||||
( year, week + 1 )
|
||||
|
||||
|
||||
getWeekDateRange : Int -> Int -> String
|
||||
getWeekDateRange year week =
|
||||
let
|
||||
mondayDate =
|
||||
getDateForWeekDay year week 0
|
||||
|
||||
fridayDate =
|
||||
getDateForWeekDay year week 4
|
||||
in
|
||||
mondayDate ++ " bis " ++ fridayDate
|
||||
|
||||
|
||||
getYearWeekFromDate : String -> ( Int, Int )
|
||||
getYearWeekFromDate dateStr =
|
||||
let
|
||||
parts =
|
||||
String.split "-" dateStr
|
||||
|
||||
year =
|
||||
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
|
||||
|
||||
month =
|
||||
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
|
||||
day =
|
||||
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
in
|
||||
( year, getISOWeek year month day )
|
||||
42
frontend/src/Utils/ErrorHandler.elm
Normal file
42
frontend/src/Utils/ErrorHandler.elm
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
module Utils.ErrorHandler exposing (handleApiError)
|
||||
|
||||
import Api.Decoders exposing (apiErrorDecoder)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Task
|
||||
import Types.Model exposing (ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
handleApiError : Http.Error -> Cmd Msg
|
||||
handleApiError error =
|
||||
let
|
||||
message =
|
||||
case error of
|
||||
Http.BadBody body ->
|
||||
case Decode.decodeString apiErrorDecoder body of
|
||||
Ok apiErr ->
|
||||
apiErr.message
|
||||
|
||||
Err _ ->
|
||||
"Ein Fehler ist aufgetreten"
|
||||
|
||||
Http.BadStatus 401 ->
|
||||
"Keine Berechtigung - bitte erneut anmelden"
|
||||
|
||||
Http.BadStatus 403 ->
|
||||
"Zugriff verweigert"
|
||||
|
||||
Http.BadStatus 404 ->
|
||||
"Ressource nicht gefunden"
|
||||
|
||||
Http.Timeout ->
|
||||
"Zeitüberschreitung - bitte erneut versuchen"
|
||||
|
||||
Http.NetworkError ->
|
||||
"Netzwerkfehler - bitte Verbindung prüfen"
|
||||
|
||||
_ ->
|
||||
"Ein unerwarteter Fehler ist aufgetreten"
|
||||
in
|
||||
Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ())
|
||||
20
frontend/src/Utils/Ports.elm
Normal file
20
frontend/src/Utils/Ports.elm
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
port module Utils.Ports exposing
|
||||
( confirmDelete
|
||||
, confirmDeleteResponse
|
||||
, removeToken
|
||||
, saveToken
|
||||
)
|
||||
|
||||
import Json.Encode as Encode
|
||||
|
||||
|
||||
port saveToken : Encode.Value -> Cmd msg
|
||||
|
||||
|
||||
port removeToken : () -> Cmd msg
|
||||
|
||||
|
||||
port confirmDelete : String -> Cmd msg
|
||||
|
||||
|
||||
port confirmDeleteResponse : (Bool -> msg) -> Sub msg
|
||||
34
frontend/src/Utils/TimeUtils.elm
Normal file
34
frontend/src/Utils/TimeUtils.elm
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
module Utils.TimeUtils exposing (calculateHours)
|
||||
|
||||
|
||||
calculateHours : String -> String -> Float
|
||||
calculateHours startTime endTime =
|
||||
let
|
||||
parseTime timeStr =
|
||||
case String.split ":" timeStr of
|
||||
[ h, m ] ->
|
||||
(String.toFloat h |> Maybe.withDefault 0)
|
||||
+ ((String.toFloat m |> Maybe.withDefault 0) / 60)
|
||||
|
||||
_ ->
|
||||
0
|
||||
|
||||
start =
|
||||
parseTime startTime
|
||||
|
||||
end =
|
||||
parseTime endTime
|
||||
in
|
||||
if end > start then
|
||||
end - start
|
||||
|
||||
else if endTime == "manual" then
|
||||
case String.toFloat startTime of
|
||||
Just time ->
|
||||
time
|
||||
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
else
|
||||
0
|
||||
1165
frontend/src/View/AdminDashboard.elm
Normal file
1165
frontend/src/View/AdminDashboard.elm
Normal file
File diff suppressed because it is too large
Load diff
99
frontend/src/View/Components/Navigation.elm
Normal file
99
frontend/src/View/Components/Navigation.elm
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
|
||||
viewWeekNavigation : Model -> Html Msg
|
||||
viewWeekNavigation model =
|
||||
let
|
||||
dateRange =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.range
|
||||
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
div [ class "box" ]
|
||||
[ nav [ class "level" ]
|
||||
[ div [ class "level-left" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-primary"
|
||||
, onClick PreviousWeek
|
||||
]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-chevron-left" ] [] ]
|
||||
, span [] [ text "Vorherige Woche" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "level-item" ]
|
||||
[ div
|
||||
[ style "display" "flex"
|
||||
, style "flex-direction" "column"
|
||||
, style "align-items" "center"
|
||||
, style "gap" "0.5rem"
|
||||
, style "min-width" "250px"
|
||||
]
|
||||
[ p
|
||||
[ class "heading"
|
||||
, style "margin" "0"
|
||||
, style "line-height" "1.2"
|
||||
]
|
||||
[ text "Kalenderwoche" ]
|
||||
, p
|
||||
[ class "title is-3"
|
||||
, style "margin" "0"
|
||||
, style "line-height" "1.2"
|
||||
]
|
||||
[ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ]
|
||||
, p
|
||||
[ class "subtitle is-6"
|
||||
, style "margin" "0"
|
||||
, style "line-height" "1.2"
|
||||
]
|
||||
[ text dateRange ]
|
||||
]
|
||||
]
|
||||
, div [ class "level-right" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-primary"
|
||||
, onClick NextWeek
|
||||
]
|
||||
[ span [] [ text "Nächste Woche" ]
|
||||
, span [ class "icon" ]
|
||||
[ i [ class "fas fa-chevron-right" ] [] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg
|
||||
viewDayMobile model dayName ( dayOfWeek, schedules ) =
|
||||
let
|
||||
dateForDay =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.dates
|
||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|> Maybe.withDefault "N/A"
|
||||
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
div [ class "box mb-4" ]
|
||||
[ p [ class "has-text-weight-bold has-text-centered mb-3" ]
|
||||
[ text (dayName ++ " - " ++ dateForDay) ]
|
||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
||||
]
|
||||
76
frontend/src/View/Components/Schedule.elm
Normal file
76
frontend/src/View/Components/Schedule.elm
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
module View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg
|
||||
viewScheduleItemWithDay model dayOfWeek schedule =
|
||||
let
|
||||
isSelected =
|
||||
List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries
|
||||
|
||||
isClickable =
|
||||
(not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing
|
||||
|
||||
boxClass =
|
||||
if isSelected then
|
||||
"box has-background-success-light"
|
||||
|
||||
else if isClickable then
|
||||
"box has-background-white"
|
||||
|
||||
else
|
||||
"box has-background-light"
|
||||
|
||||
typeText =
|
||||
if schedule.scheduleType == "break" then
|
||||
" (Pause)"
|
||||
|
||||
else
|
||||
""
|
||||
|
||||
cursorStyle =
|
||||
if isClickable then
|
||||
"pointer"
|
||||
|
||||
else
|
||||
"not-allowed"
|
||||
|
||||
opacity =
|
||||
if isClickable || isSelected then
|
||||
"1"
|
||||
|
||||
else
|
||||
"0.6"
|
||||
in
|
||||
div
|
||||
[ class boxClass
|
||||
, onClick
|
||||
(if isClickable then
|
||||
ToggleScheduleSelection schedule.id dayOfWeek
|
||||
|
||||
else
|
||||
FetchSchedules
|
||||
)
|
||||
, style "cursor" cursorStyle
|
||||
, style "margin-bottom" "0.5rem"
|
||||
, style "padding" "0.75rem"
|
||||
, style "opacity" opacity
|
||||
, style "transition" "all 0.2s ease"
|
||||
, style "border"
|
||||
(if isClickable && not isSelected then
|
||||
"2px solid transparent"
|
||||
|
||||
else
|
||||
"2px solid currentColor"
|
||||
)
|
||||
]
|
||||
[ p [ class "has-text-weight-bold is-size-7" ]
|
||||
[ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
|
||||
, p [ class "is-size-7" ]
|
||||
[ text (schedule.title ++ typeText) ]
|
||||
]
|
||||
66
frontend/src/View/Components/Toast.elm
Normal file
66
frontend/src/View/Components/Toast.elm
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
module View.Components.Toast exposing (viewToasts)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule, Toast, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.TimeUtils exposing (calculateHours)
|
||||
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
|
||||
viewToasts : List Toast -> Html Msg
|
||||
viewToasts toasts =
|
||||
div [ class "toast-container" ]
|
||||
(List.map viewToast toasts)
|
||||
|
||||
|
||||
viewToast : Toast -> Html Msg
|
||||
viewToast toast =
|
||||
let
|
||||
toastClass =
|
||||
case toast.toastType of
|
||||
ErrorToast ->
|
||||
"toast-error"
|
||||
|
||||
SuccessToast ->
|
||||
"toast-success"
|
||||
|
||||
InfoToast ->
|
||||
"toast-info"
|
||||
|
||||
WarningToast ->
|
||||
"toast-warning"
|
||||
|
||||
icon =
|
||||
case toast.toastType of
|
||||
ErrorToast ->
|
||||
"fas fa-exclamation-circle"
|
||||
|
||||
SuccessToast ->
|
||||
"fas fa-check-circle"
|
||||
|
||||
InfoToast ->
|
||||
"fas fa-info-circle"
|
||||
|
||||
WarningToast ->
|
||||
"fas fa-exclamation-triangle"
|
||||
in
|
||||
div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ]
|
||||
[ div [ class "toast-content" ]
|
||||
[ span [ class "toast-icon" ]
|
||||
[ i [ class icon ] [] ]
|
||||
, span [ class "toast-message" ] [ text toast.message ]
|
||||
]
|
||||
, if toast.dismissible then
|
||||
button
|
||||
[ class "toast-close"
|
||||
, onClick (DismissToast toast.id)
|
||||
, attribute "aria-label" "Schließen"
|
||||
]
|
||||
[ i [ class "fas fa-times" ] [] ]
|
||||
|
||||
else
|
||||
text ""
|
||||
]
|
||||
57
frontend/src/View/Login.elm
Normal file
57
frontend/src/View/Login.elm
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
module View.Login exposing (viewLogin)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
viewLogin : Model -> Html Msg
|
||||
viewLogin model =
|
||||
section [ class "section" ]
|
||||
[ div [ class "container" ]
|
||||
[ div [ class "columns is-centered" ]
|
||||
[ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
|
||||
[ div [ class "box" ]
|
||||
[ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ]
|
||||
, div [ class "field" ]
|
||||
[ label [ class "label" ] [ text "Benutzername" ]
|
||||
, div [ class "control" ]
|
||||
[ input
|
||||
[ class "input"
|
||||
, type_ "text"
|
||||
, placeholder "Benutzername"
|
||||
, value model.username
|
||||
, onInput UpdateUsername
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "field" ]
|
||||
[ label [ class "label" ] [ text "Passwort" ]
|
||||
, div [ class "control" ]
|
||||
[ input
|
||||
[ class "input"
|
||||
, type_ "password"
|
||||
, placeholder "Passwort"
|
||||
, value model.password
|
||||
, onInput UpdatePassword
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "field" ]
|
||||
[ div [ class "control" ]
|
||||
[ button
|
||||
[ class "button is-primary is-fullwidth"
|
||||
, onClick Login
|
||||
]
|
||||
[ text "Anmelden" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
338
frontend/src/View/UserDashboard.elm
Normal file
338
frontend/src/View/UserDashboard.elm
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
module View.UserDashboard exposing (viewUserDashboard)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.TimeUtils exposing (calculateHours)
|
||||
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
|
||||
viewUserDashboard : Model -> Html Msg
|
||||
viewUserDashboard model =
|
||||
div []
|
||||
[ nav [ class "navbar is-primary" ]
|
||||
[ div [ class "navbar-brand" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
|
||||
]
|
||||
, a
|
||||
[ class
|
||||
("navbar-burger"
|
||||
++ (if model.mobileMenuOpen then
|
||||
" is-active"
|
||||
|
||||
else
|
||||
""
|
||||
)
|
||||
)
|
||||
, attribute "role" "navigation"
|
||||
, attribute "aria-label" "menu"
|
||||
, attribute "aria-expanded"
|
||||
(if model.mobileMenuOpen then
|
||||
"true"
|
||||
|
||||
else
|
||||
"false"
|
||||
)
|
||||
, onClick ToggleMobileMenu
|
||||
]
|
||||
[ span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
]
|
||||
]
|
||||
, div
|
||||
[ id "navbarUser"
|
||||
, class
|
||||
("navbar-menu"
|
||||
++ (if model.mobileMenuOpen then
|
||||
" is-active"
|
||||
|
||||
else
|
||||
""
|
||||
)
|
||||
)
|
||||
]
|
||||
[ div [ class "navbar-end" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ span [ class "has-text-white mr-2" ] [ text model.username ]
|
||||
]
|
||||
, div [ class "navbar-item" ]
|
||||
[ button [ class "button is-light", onClick Logout ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-sign-out-alt" ] [] ]
|
||||
, span [] [ text "Abmelden" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, section [ class "section" ]
|
||||
[ div [ class "container" ]
|
||||
[ viewWeekNavigation model
|
||||
, h2 [ class "title" ] [ text "Stundenplan" ]
|
||||
, if model.hasEntriesForCurrentWeek && not model.weekEditMode then
|
||||
div [ class "notification is-success" ]
|
||||
[ div [ class "level" ]
|
||||
[ div [ class "level-left" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-check-circle" ] [] ]
|
||||
, span [] [ text "Diese Woche wurde bereits erfasst" ]
|
||||
]
|
||||
]
|
||||
, div [ class "level-right" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-warning"
|
||||
, onClick EnableEditMode
|
||||
, disabled model.isProcessing
|
||||
]
|
||||
[ text "Bearbeiten" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
else if model.weekEditMode then
|
||||
div [ class "notification is-warning" ]
|
||||
[ div [ class "level" ]
|
||||
[ div [ class "level-left" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-edit" ] [] ]
|
||||
, span [] [ text "Bearbeitungsmodus aktiv" ]
|
||||
]
|
||||
]
|
||||
, div [ class "level-right" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-danger is-small mr-2"
|
||||
, onClick DeleteWeekEntries
|
||||
, disabled model.isProcessing
|
||||
]
|
||||
[ text "Einträge löschen" ]
|
||||
, button
|
||||
[ class "button is-light is-small"
|
||||
, onClick DisableEditMode
|
||||
]
|
||||
[ text "Abbrechen" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
else
|
||||
div [ class "notification is-info is-light" ]
|
||||
[ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ]
|
||||
, viewScheduleGridWithWeek model
|
||||
, if not model.hasEntriesForCurrentWeek || model.weekEditMode then
|
||||
div [ class "field mt-4" ]
|
||||
[ div [ class "control" ]
|
||||
[ button
|
||||
[ class "button is-primary is-large is-fullwidth"
|
||||
, onClick SaveTimeEntries
|
||||
, disabled (List.isEmpty model.selectedEntries || model.isProcessing)
|
||||
]
|
||||
[ if model.isProcessing then
|
||||
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
|
||||
|
||||
else
|
||||
text ""
|
||||
, text
|
||||
(if model.weekEditMode then
|
||||
"Änderungen speichern"
|
||||
|
||||
else
|
||||
"Speichern"
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
else
|
||||
text ""
|
||||
, h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ]
|
||||
, viewUserYearlyTotal model
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewScheduleGridWithWeek : Model -> Html Msg
|
||||
viewScheduleGridWithWeek model =
|
||||
let
|
||||
days =
|
||||
[ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ]
|
||||
|
||||
groupedSchedules =
|
||||
List.range 0 4
|
||||
|> List.map
|
||||
(\day ->
|
||||
( day, List.filter (\s -> s.dayOfWeek == day) model.schedules )
|
||||
)
|
||||
in
|
||||
div []
|
||||
[ div [ class "is-hidden-mobile" ]
|
||||
[ div [ class "table-container" ]
|
||||
[ table [ class "table is-bordered is-fullwidth" ]
|
||||
[ thead []
|
||||
[ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days)
|
||||
]
|
||||
, tbody []
|
||||
[ tr []
|
||||
(List.map (viewDayColumnWithWeek model) groupedSchedules)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "is-hidden-tablet" ]
|
||||
(List.map2 (viewDayMobile model) days groupedSchedules)
|
||||
]
|
||||
|
||||
|
||||
viewUserYearlyTotal : Model -> Html Msg
|
||||
viewUserYearlyTotal model =
|
||||
let
|
||||
yearlyTotal =
|
||||
model.timeEntries
|
||||
|> List.map
|
||||
(\entry ->
|
||||
if entry.entryType == "lesson" then
|
||||
1.0
|
||||
|
||||
else
|
||||
Utils.TimeUtils.calculateHours entry.startTime entry.endTime
|
||||
)
|
||||
|> List.sum
|
||||
|
||||
userTarget =
|
||||
List.filter (\u -> not u.isAdmin) model.users
|
||||
|> List.head
|
||||
|> Maybe.map .yearlyWorkHours
|
||||
|> Maybe.withDefault 60
|
||||
|
||||
remaining =
|
||||
userTarget - yearlyTotal
|
||||
|
||||
progressPercent =
|
||||
Basics.min 100 (yearlyTotal / userTarget * 100)
|
||||
|
||||
progressColor =
|
||||
if remaining <= 0 then
|
||||
"is-success"
|
||||
|
||||
else if yearlyTotal >= userTarget * 0.8 then
|
||||
"is-info"
|
||||
|
||||
else
|
||||
"is-warning"
|
||||
in
|
||||
div [ class "box" ]
|
||||
[ div [ class "columns" ]
|
||||
[ div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Jahresenziel" ]
|
||||
, p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ]
|
||||
]
|
||||
, div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Geleistete Stunden" ]
|
||||
, p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ]
|
||||
]
|
||||
, div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Restliche Stunden" ]
|
||||
, p
|
||||
[ class
|
||||
("title is-4 "
|
||||
++ (if remaining <= 0 then
|
||||
"has-text-success"
|
||||
|
||||
else
|
||||
"has-text-warning"
|
||||
)
|
||||
)
|
||||
]
|
||||
[ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ]
|
||||
]
|
||||
]
|
||||
, progress
|
||||
[ class ("progress " ++ progressColor)
|
||||
, value (String.fromFloat progressPercent)
|
||||
, Html.Attributes.max "100"
|
||||
]
|
||||
[ text (String.fromFloat progressPercent ++ "%") ]
|
||||
]
|
||||
|
||||
|
||||
viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg
|
||||
viewDayColumnWithWeek model ( dayOfWeek, schedules ) =
|
||||
let
|
||||
dateForDay =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.dates
|
||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|> Maybe.withDefault "N/A"
|
||||
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ]
|
||||
[ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ]
|
||||
[ text dateForDay ]
|
||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
||||
]
|
||||
|
||||
|
||||
viewUserWeeklySummary : Model -> Html Msg
|
||||
viewUserWeeklySummary model =
|
||||
case model.userWeeklySummary of
|
||||
Just summary ->
|
||||
let
|
||||
progressPercent =
|
||||
Basics.min 100 (summary.totalHours / summary.targetHours * 100)
|
||||
|
||||
progressColor =
|
||||
if summary.totalHours >= summary.targetHours then
|
||||
"is-success"
|
||||
|
||||
else if summary.totalHours >= summary.targetHours * 0.8 then
|
||||
"is-info"
|
||||
|
||||
else
|
||||
"is-warning"
|
||||
in
|
||||
div [ class "box" ]
|
||||
[ div [ class "columns" ]
|
||||
[ div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ]
|
||||
, p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ]
|
||||
, p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ]
|
||||
]
|
||||
, div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Verbleibend" ]
|
||||
, p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ]
|
||||
[ text (String.fromFloat summary.remainingHours ++ " Std.") ]
|
||||
, if summary.remainingHours < 0 then
|
||||
p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ]
|
||||
|
||||
else
|
||||
p [ class "subtitle is-6" ] [ text "" ]
|
||||
]
|
||||
]
|
||||
, progress
|
||||
[ class ("progress " ++ progressColor)
|
||||
, value (String.fromFloat progressPercent)
|
||||
, Html.Attributes.max "100"
|
||||
]
|
||||
[ text (String.fromFloat progressPercent ++ "%") ]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
div [ class "box" ]
|
||||
[ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ]
|
||||
]
|
||||
29
frontend/src/View/View.elm
Normal file
29
frontend/src/View/View.elm
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
module View.View exposing (view)
|
||||
|
||||
import Html exposing (Html, div)
|
||||
import Html.Attributes exposing (class)
|
||||
import Types.Model exposing (Model)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (Page(..))
|
||||
import View.AdminDashboard exposing (viewAdminDashboard)
|
||||
import View.Components.Toast exposing (viewToasts)
|
||||
import View.Login exposing (viewLogin)
|
||||
import View.UserDashboard exposing (viewUserDashboard)
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "app-container" ]
|
||||
[ viewToasts model.toasts
|
||||
, div [ class "container" ]
|
||||
[ case model.page of
|
||||
LoginPage ->
|
||||
viewLogin model
|
||||
|
||||
UserDashboard ->
|
||||
viewUserDashboard model
|
||||
|
||||
AdminDashboard ->
|
||||
viewAdminDashboard model
|
||||
]
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue