diff --git a/README.md b/README.md index 732cdbb..d35de51 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ http://localhost:8080 **Standard-Anmeldedaten:** - Benutzername: `admin` -- Passwort: Das in `docker-compose.yml` unter `INITIAL_ADMIN_PASSWORD` festgelegte Passwort. +- Passwort: `admin123` ⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung! @@ -179,15 +179,13 @@ 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** | -| `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 | +| 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 | ### Docker-Volumes @@ -205,7 +203,7 @@ Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert. ### Ersteinrichtung als Administrator -1. **Anmelden** mit den Standard-Credentials (admin/das initiale Passwort aus der Konfiguration) +1. **Anmelden** mit den Standard-Credentials (admin/admin123) 2. **Admin-Passwort ändern**: - Gehe zu "Benutzer" Tab @@ -313,7 +311,7 @@ Benutzer-Anmeldung ```json { "username": "admin", - "password": "" + "password": "admin123" } ``` @@ -770,6 +768,6 @@ Todo --- -**Version**: 1.5.0 +**Version**: 1.1.0 **Letztes Update**: November 2025 **Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter diff --git a/backend/database.go b/backend/database.go index 66f3e54..bd15b02 100644 --- a/backend/database.go +++ b/backend/database.go @@ -580,47 +580,3 @@ 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 -} diff --git a/backend/errors.go b/backend/errors.go deleted file mode 100644 index 7ee17bd..0000000 --- a/backend/errors.go +++ /dev/null @@ -1,205 +0,0 @@ -package main - -import ( - "fmt" - "net/http" -) - -type ErrorCode string - -const ( - // Authentifizierung - ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS" - ErrUnauthorized ErrorCode = "UNAUTHORIZED" - ErrTokenExpired ErrorCode = "TOKEN_EXPIRED" - ErrAccessDenied ErrorCode = "ACCESS_DENIED" - - // Validierung - ErrInvalidInput ErrorCode = "INVALID_INPUT" - ErrMissingField ErrorCode = "MISSING_FIELD" - ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT" - ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT" - - // Ressourcen - ErrNotFound ErrorCode = "NOT_FOUND" - ErrAlreadyExists ErrorCode = "ALREADY_EXISTS" - ErrCannotDelete ErrorCode = "CANNOT_DELETE" - ErrProtectedUser ErrorCode = "PROTECTED_USER" - ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR" - - // Datenbank - ErrDatabase ErrorCode = "DATABASE_ERROR" - ErrTransaction ErrorCode = "TRANSACTION_ERROR" - ErrQueryFailed ErrorCode = "QUERY_FAILED" - - // Server - ErrInternal ErrorCode = "INTERNAL_ERROR" - ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE" -) - -type AppError struct { - Code ErrorCode `json:"code"` - Message string `json:"message"` - UserMsg string `json:"user_message"` - HTTPStatus int `json:"-"` - Internal error `json:"-"` -} - -func (e *AppError) Error() string { - if e.Internal != nil { - return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal) - } - return fmt.Sprintf("[%s] %s", e.Code, e.Message) -} - -func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError { - return &AppError{ - Code: code, - Message: message, - UserMsg: userMsg, - HTTPStatus: httpStatus, - Internal: internal, - } -} - -func ErrInvalidCredentialsMsg() *AppError { - return NewAppError( - ErrInvalidCredentials, - "Invalid username or password", - "Benutzername oder Passwort ungültig", - http.StatusUnauthorized, - nil, - ) -} - -func ErrUnauthorizedMsg() *AppError { - return NewAppError( - ErrUnauthorized, - "Unauthorized access", - "Keine Berechtigung für diese Aktion", - http.StatusUnauthorized, - nil, - ) -} - -func ErrTokenExpiredMsg() *AppError { - return NewAppError( - ErrTokenExpired, - "Token has expired", - "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an", - http.StatusUnauthorized, - nil, - ) -} - -func ErrAccessDeniedMsg() *AppError { - return NewAppError( - ErrAccessDenied, - "Access denied - admin privileges required", - "Zugriff verweigert. Administrator-Rechte erforderlich", - http.StatusForbidden, - nil, - ) -} - -func ErrInvalidInputMsg(field string) *AppError { - return NewAppError( - ErrInvalidInput, - fmt.Sprintf("Invalid input for field: %s", field), - fmt.Sprintf("Ungültige Eingabe im Feld: %s", field), - http.StatusBadRequest, - nil, - ) -} - -func ErrMissingFieldMsg(field string) *AppError { - return NewAppError( - ErrMissingField, - fmt.Sprintf("Required field missing: %s", field), - fmt.Sprintf("Pflichtfeld fehlt: %s", field), - http.StatusBadRequest, - nil, - ) -} - -func ErrNotFoundMsg(resource string) *AppError { - return NewAppError( - ErrNotFound, - fmt.Sprintf("%s not found", resource), - fmt.Sprintf("%s nicht gefunden", resource), - http.StatusNotFound, - nil, - ) -} - -func ErrAlreadyExistsMsg(resource string) *AppError { - return NewAppError( - ErrAlreadyExists, - fmt.Sprintf("%s already exists", resource), - fmt.Sprintf("%s existiert bereits", resource), - http.StatusConflict, - nil, - ) -} - -func ErrCannotDeleteMsg(resource, reason string) *AppError { - return NewAppError( - ErrCannotDelete, - fmt.Sprintf("Cannot delete %s: %s", resource, reason), - fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason), - http.StatusBadRequest, - nil, - ) -} - -func ErrProtectedUserMsg() *AppError { - return NewAppError( - ErrProtectedUser, - "Cannot modify protected admin user", - "Der Admin-Benutzer ist geschützt und kann nicht geändert werden", - http.StatusForbidden, - nil, - ) -} - -func ErrNoActiveSchoolYearMsg() *AppError { - return NewAppError( - ErrNoActiveSchool, - "No active school year configured", - "Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr", - http.StatusNotFound, - nil, - ) -} - -func ErrDatabaseMsg(internal error) *AppError { - return NewAppError( - ErrDatabase, - "Database operation failed", - "Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut", - http.StatusInternalServerError, - internal, - ) -} - -func ErrInternalMsg(internal error) *AppError { - return NewAppError( - ErrInternal, - "Internal server error", - "Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut", - http.StatusInternalServerError, - internal, - ) -} - -type ErrorResponse struct { - Code ErrorCode `json:"code"` - Message string `json:"message"` -} - -func (e *AppError) ToResponse() ErrorResponse { - return ErrorResponse{ - Code: e.Code, - Message: e.UserMsg, - } -} diff --git a/backend/go.mod b/backend/go.mod index 2a1d344..7b185e7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,9 +12,7 @@ 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 diff --git a/backend/go.sum b/backend/go.sum index 6c63134..fee7803 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,8 +4,6 @@ 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= @@ -13,8 +11,6 @@ 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= diff --git a/backend/handlers.go b/backend/handlers.go index 06b3f57..e9567d6 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -3,13 +3,10 @@ package main import ( "database/sql" "fmt" - "log" "net/http" "strconv" - "strings" "time" - "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" ) @@ -18,60 +15,24 @@ 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 HandleError(c, ErrInvalidInputMsg("Login-Daten")) - } - - if req.Username == "" { - return HandleError(c, ErrMissingFieldMsg("Benutzername")) - } - if req.Password == "" { - return HandleError(c, ErrMissingFieldMsg("Passwort")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } user, err := GetUserByUsername(app.DB, req.Username) if err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrInvalidCredentialsMsg()) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - return HandleError(c, ErrInvalidCredentialsMsg()) + return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") } token, err := createToken(user.ID, user.Username, user.IsAdmin) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") } response := LoginResponse{ @@ -86,7 +47,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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, schedules) } @@ -94,40 +55,24 @@ func (app *App) GetSchedulesHandler(c echo.Context) error { func (app *App) CreateScheduleHandler(c echo.Context) error { var schedule Schedule if err := c.Bind(&schedule); err != nil { - return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten")) - } - - if schedule.StartTime == "" { - return HandleError(c, ErrMissingFieldMsg("Startzeit")) - } - if schedule.EndTime == "" { - return HandleError(c, ErrMissingFieldMsg("Endzeit")) - } - if schedule.Title == "" { - return HandleError(c, ErrMissingFieldMsg("Titel")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } if err := CreateSchedule(app.DB, &schedule); err != nil { - if isDuplicateError(err) { - return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"}) } func (app *App) DeleteScheduleHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid id") } if err := DeleteSchedule(app.DB, id); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Stundenplan")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusNoContent) @@ -136,7 +81,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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if hours == nil { hours = []WeeklyHours{} @@ -145,6 +90,11 @@ 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"` @@ -153,17 +103,7 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { } if err := c.Bind(&req); err != nil { - 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")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } entry := TimeEntry{ @@ -175,16 +115,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { } if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusNoContent) + return c.NoContent(http.StatusCreated) } func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if users == nil { users = []User{} @@ -195,62 +135,39 @@ func (app *App) GetUsersHandler(c echo.Context) error { func (app *App) DeleteUserHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) - } - - if id == 1 { - return HandleError(c, ErrProtectedUserMsg()) + return echo.NewHTTPError(http.StatusBadRequest, "invalid id") } if err := DeleteUser(app.DB, id); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusNoContent) } func (app *App) CreateTimeEntryHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) var entry TimeEntry if err := c.Bind(&entry); err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } - if entry.Date == "" { - return HandleError(c, ErrMissingFieldMsg("Datum")) - } - if entry.StartTime == "" { - return HandleError(c, ErrMissingFieldMsg("Startzeit")) - } - if entry.EndTime == "" { - return HandleError(c, ErrMissingFieldMsg("Endzeit")) - } - - entry.UserID = claims.UserID + entry.UserID = userID if err := CreateTimeEntry(app.DB, &entry); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) } func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) - entries, err := GetTimeEntriesByUser(app.DB, claims.UserID) + entries, err := GetTimeEntriesByUser(app.DB, userID) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if entries == nil { entries = []TimeEntry{} @@ -262,16 +179,12 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeekDates(c echo.Context) error { year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Jahr")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Woche")) - } - - if week < 1 || week > 53 { - return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") } dates := calculateWeekDates(year, week) @@ -279,24 +192,21 @@ func (app *App) GetWeekDates(c echo.Context) error { } func (app *App) CheckWeekHasEntries(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Jahr")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Woche")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") } - hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week) + hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries}) @@ -305,7 +215,7 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error { func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { entries, err := GetAllTimeEntries(app.DB) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if entries == nil { entries = []TimeEntry{} @@ -316,7 +226,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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if hours == nil { hours = []WeeklyHours{} @@ -325,23 +235,20 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error { } func (app *App) DeleteWeekEntries(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Jahr")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Woche")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") } - if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusNoContent) @@ -403,83 +310,52 @@ type BatchTimeEntryRequest struct { } func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) var req BatchTimeEntryRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) - } - - if len(req.Entries) == 0 { - return HandleError(c, ErrMissingFieldMsg("Zeiteinträge")) - } - - if len(req.Entries) > 0 { - firstDate := req.Entries[0].Date - t, err := time.Parse("2006-01-02", firstDate) - if err != nil { - return HandleError(c, ErrInvalidInputMsg("Datum-Format")) - } - year, week := t.ISOWeek() - - if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) - } + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } tx, err := app.DB.Begin() if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") } defer tx.Rollback() stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)") if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") } defer stmt.Close() for _, entry := range req.Entries { - _, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + _, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "insert error") } } if err := tx.Commit(); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "commit error") } - return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) } func (app *App) UpdateUserHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) - } - - if userID == 1 { - return HandleError(c, ErrProtectedUserMsg()) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") } var req UpdateUserRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) - } - - if req.YearlyHours <= 0 { - return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusOK) @@ -488,28 +364,21 @@ func (app *App) UpdateUserHandler(c echo.Context) error { func (app *App) ResetPasswordHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") } var req ResetPasswordRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Passwort-Daten")) - } - - if len(req.NewPassword) < 6 { - return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") } if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusOK) @@ -518,29 +387,16 @@ func (app *App) ResetPasswordHandler(c echo.Context) error { func (app *App) UpdateTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") } var req UpdateTimeEntryRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) - } - - if req.Date == "" { - return HandleError(c, ErrMissingFieldMsg("Datum")) - } - if req.StartTime == "" { - return HandleError(c, ErrMissingFieldMsg("Startzeit")) - } - if req.EndTime == "" { - return HandleError(c, ErrMissingFieldMsg("Endzeit")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusOK) @@ -549,31 +405,22 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error { func (app *App) DeleteTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") } if err := DeleteTimeEntry(app.DB, entryID); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusNoContent) } func (app *App) GetMyInfoHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) - user, err := GetUserByID(app.DB, claims.UserID) + user, err := GetUserByID(app.DB, userID) if err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, user) @@ -582,22 +429,12 @@ 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 HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) - } - - if req.Username == "" { - return HandleError(c, ErrMissingFieldMsg("Benutzername")) - } - if req.Password == "" { - return HandleError(c, ErrMissingFieldMsg("Passwort")) - } - if len(req.Password) < 6 { - return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") } if req.YearlyHours == 0 { @@ -605,10 +442,7 @@ func (app *App) CreateUserHandler(c echo.Context) error { } if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil { - if isDuplicateError(err) { - return HandleError(c, ErrAlreadyExistsMsg("Benutzername")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusCreated) @@ -617,7 +451,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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if years == nil { years = []SchoolYear{} @@ -628,24 +462,11 @@ 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 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")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil { - if isDuplicateError(err) { - return HandleError(c, ErrAlreadyExistsMsg("Schuljahr")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusCreated) @@ -654,14 +475,11 @@ 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 HandleError(c, ErrInvalidInputMsg("Schuljahr-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID") } if err := SetActiveSchoolYear(app.DB, id); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Schuljahr")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusNoContent) @@ -670,7 +488,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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } if year == nil { return c.JSON(http.StatusOK, map[string]any{"active": false}) @@ -679,22 +497,24 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { } func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error { - schoolYear, err := GetActiveSchoolYear(app.DB) - if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + isAdmin, _ := c.Get("is_admin").(bool) + if !isAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs") } - if schoolYear == nil { - return HandleError(c, ErrNoActiveSchoolYearMsg()) + + schoolYear, err := GetActiveSchoolYear(app.DB) + if err != nil || schoolYear == nil { + return echo.NewHTTPError(http.StatusNotFound, "No active school year found") } summary, err := GetYearlyHoursSummary(app.DB) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error()) } filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name) @@ -703,26 +523,3 @@ 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) -} diff --git a/backend/main.go b/backend/main.go index 84cb7f1..78ccf01 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,7 +4,6 @@ import ( "log" "net/http" "os" - "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -25,20 +24,8 @@ func main() { e.Use(middleware.Logger()) e.Use(middleware.Recover()) - - // CORS Configuration - allowOrigins := []string{"*"} // Default for development - if os.Getenv("ENVIRONMENT") == "production" { - origins := os.Getenv("CORS_ALLOWED_ORIGINS") - if origins != "" { - allowOrigins = strings.Split(origins, ",") - } else { - log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.") - } - } - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: allowOrigins, + AllowOrigins: []string{"*"}, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) @@ -80,7 +67,6 @@ 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) } diff --git a/backend/middleware.go b/backend/middleware.go index 78d693c..4ee2231 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,13 +1,17 @@ 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" @@ -24,43 +28,104 @@ 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)), - }, } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + + claimsWithExp := map[string]any{ + "user_id": claims.UserID, + "username": claims.Username, + "is_admin": claims.IsAdmin, + "exp": time.Now().Add(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 } func JWTMiddleware() echo.MiddlewareFunc { - return echojwt.WithConfig(echojwt.Config{ - NewClaimsFunc: func(c echo.Context) jwt.Claims { - return new(Claims) - }, - SigningKey: jwtSecret, - }) + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "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) + } + } } func AdminMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - user, ok := c.Get("user").(*jwt.Token) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid") - } - - claims, ok := user.Claims.(*Claims) - if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims") - } - - if !claims.IsAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required") + isAdmin, ok := c.Get("is_admin").(bool) + if !ok || !isAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Access denied") } return next(c) } diff --git a/backend/models.go b/backend/models.go index 8429bb6..1348146 100644 --- a/backend/models.go +++ b/backend/models.go @@ -1,9 +1,6 @@ package main -import ( - "github.com/golang-jwt/jwt/v5" - "time" -) +import "time" type TimeEntry struct { ID int `json:"id"` @@ -99,5 +96,4 @@ type Claims struct { UserID int `json:"user_id"` Username string `json:"username"` IsAdmin bool `json:"is_admin"` - jwt.RegisteredClaims } diff --git a/backend/static/index.html b/backend/static/index.html index 12ae1c0..6be48fa 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1,338 +1,149 @@ - + - - - - + + + Zeiterfassung - - - - - + + + + + -
- + - diff --git a/frontend/public/index.html b/frontend/public/index.html index 12ae1c0..71337d4 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,338 +1,164 @@ - + - - - - + + + Zeiterfassung - - - - - + + + + + + + -
- + - diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm deleted file mode 100644 index 0de5c4e..0000000 --- a/frontend/src/Api/Auth.elm +++ /dev/null @@ -1,21 +0,0 @@ -module Api.Auth exposing (loginRequest) - -import Api.Decoders exposing (loginDecoder) -import Http -import Json.Encode as Encode -import Types.Api exposing (LoginResult) -import Types.Msg exposing (Msg(..)) - - -loginRequest : String -> String -> Cmd Msg -loginRequest username password = - Http.post - { url = "/api/login" - , body = - Http.jsonBody <| - Encode.object - [ ( "username", Encode.string username ) - , ( "password", Encode.string password ) - ] - , expect = Http.expectJson LoginResponse loginDecoder - } diff --git a/frontend/src/Api/Decoders.elm b/frontend/src/Api/Decoders.elm deleted file mode 100644 index cb72efa..0000000 --- a/frontend/src/Api/Decoders.elm +++ /dev/null @@ -1,109 +0,0 @@ -module Api.Decoders exposing - ( apiErrorDecoder - , loginDecoder - , scheduleDecoder - , schoolYearDecoder - , timeEntryDecoder - , userDecoder - , weekDatesDecoder - , weeklyHoursDecoder - , yearlyHoursSummaryDecoder - ) - -import Dict -import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) -import Types.Api exposing (ApiError, LoginResult) -import Types.Model exposing (..) - - -loginDecoder : Decoder LoginResult -loginDecoder = - Decode.map3 LoginResult - (field "token" string) - (field "username" string) - (field "is_admin" bool) - - -scheduleDecoder : Decoder Schedule -scheduleDecoder = - Decode.map6 Schedule - (field "id" int) - (field "day_of_week" int) - (field "start_time" string) - (field "end_time" string) - (field "type" string) - (field "title" string) - - -timeEntryDecoder : Decoder TimeEntry -timeEntryDecoder = - Decode.map8 TimeEntry - (field "id" int) - (field "user_id" int) - (field "schedule_id" int) - (field "date" string) - (field "type" string) - (field "username" string) - (field "start_time" string) - (field "end_time" string) - - -userDecoder : Decoder User -userDecoder = - Decode.map4 User - (field "id" int) - (field "username" string) - (field "is_admin" bool) - (field "yearly_hours" float) - - -weekDatesDecoder : Decoder WeekDates -weekDatesDecoder = - Decode.map4 WeekDates - (field "year" int) - (field "week" int) - (field "dates" (Decode.dict string) |> Decode.map Dict.toList) - (field "range" string) - - -weeklyHoursDecoder : Decoder WeeklyHours -weeklyHoursDecoder = - Decode.map7 WeeklyHours - (field "user_id" int) - (field "username" string) - (field "year" int) - (field "week" int) - (field "total_hours" float) - (field "expected_hours" float) - (field "remaining_hours" float) - - -yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary -yearlyHoursSummaryDecoder = - Decode.succeed YearlyHoursSummary - |> Decode.andThen (\f -> Decode.map f (field "user_id" int)) - |> Decode.andThen (\f -> Decode.map f (field "username" string)) - |> Decode.andThen (\f -> Decode.map f (field "year" int)) - |> Decode.andThen (\f -> Decode.map f (field "week" int)) - |> Decode.andThen (\f -> Decode.map f (field "total_hours" float)) - |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float)) - |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float)) - |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float)) - |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float)) - - -schoolYearDecoder : Decoder SchoolYear -schoolYearDecoder = - Decode.map5 SchoolYear - (field "id" int) - (field "name" string) - (field "start_date" string) - (field "end_date" string) - (field "is_active" bool) - - -apiErrorDecoder : Decoder ApiError -apiErrorDecoder = - Decode.map2 ApiError - (field "code" string) - (field "message" string) diff --git a/frontend/src/Api/Schedule.elm b/frontend/src/Api/Schedule.elm deleted file mode 100644 index f966645..0000000 --- a/frontend/src/Api/Schedule.elm +++ /dev/null @@ -1,120 +0,0 @@ -module Api.Schedule exposing - ( createSchedule - , deleteSchedule - , fetchSchedules - , saveTimeEntriesForWeek - ) - -import Api.Decoders exposing (scheduleDecoder) -import Http -import Json.Decode -import Json.Encode as Encode -import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates) -import Types.Msg exposing (Msg(..)) - - -fetchSchedules : Maybe String -> Cmd Msg -fetchSchedules maybeToken = - case maybeToken of - Just token -> - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/schedules" - , body = Http.emptyBody - , expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder) - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -createSchedule : String -> NewSchedule -> Cmd Msg -createSchedule token schedule = - case String.toInt schedule.dayOfWeek of - Just day -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules" - , body = - Http.jsonBody <| - Encode.object - [ ( "day_of_week", Encode.int day ) - , ( "start_time", Encode.string schedule.startTime ) - , ( "end_time", Encode.string schedule.endTime ) - , ( "type", Encode.string schedule.scheduleType ) - , ( "title", Encode.string schedule.title ) - ] - , expect = Http.expectWhatever ScheduleCreated - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -deleteSchedule : String -> Int -> Cmd Msg -deleteSchedule token scheduleId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId - , body = Http.emptyBody - , expect = Http.expectWhatever ScheduleDeleted - , timeout = Nothing - , tracker = Nothing - } - - -saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg -saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = - case maybeWeekDates of - Nothing -> - Cmd.none - - Just weekDates -> - let - getScheduleById id = - List.filter (\s -> s.id == id) schedules |> List.head - - getDateForDay dayOfWeek = - weekDates.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - - createEntryData entry = - case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of - ( Just schedule, Just dateStr ) -> - Just <| - Encode.object - [ ( "schedule_id", Encode.int entry.scheduleId ) - , ( "date", Encode.string dateStr ) - , ( "type", Encode.string schedule.scheduleType ) - , ( "start_time", Encode.string schedule.startTime ) - , ( "end_time", Encode.string schedule.endTime ) - ] - - _ -> - Nothing - - entriesData = - List.filterMap createEntryData selectedEntries - in - if List.isEmpty entriesData then - Cmd.none - - else - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/time-entries/batch" - , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] - , expect = Http.expectWhatever TimeEntriesSaved - , timeout = Nothing - , tracker = Nothing - } diff --git a/frontend/src/Api/SchoolYear.elm b/frontend/src/Api/SchoolYear.elm deleted file mode 100644 index be1fb63..0000000 --- a/frontend/src/Api/SchoolYear.elm +++ /dev/null @@ -1,85 +0,0 @@ -module Api.SchoolYear exposing - ( activateSchoolYear - , createSchoolYear - , deleteSchoolYear - , fetchActiveSchoolYear - , fetchSchoolYears - ) - -import Api.Decoders exposing (schoolYearDecoder) -import Http -import Json.Decode as Decode -import Json.Encode as Encode -import Types.Model exposing (NewSchoolYear) -import Types.Msg exposing (Msg(..)) - - -fetchSchoolYears : String -> Cmd Msg -fetchSchoolYears token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years" - , body = Http.emptyBody - , expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -fetchActiveSchoolYear : String -> Cmd Msg -fetchActiveSchoolYear token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/school-year/active" - , body = Http.emptyBody - , expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder - , timeout = Nothing - , tracker = Nothing - } - - -createSchoolYear : String -> NewSchoolYear -> Cmd Msg -createSchoolYear token schoolYear = - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years" - , body = - Http.jsonBody <| - Encode.object - [ ( "name", Encode.string schoolYear.name ) - , ( "start_date", Encode.string schoolYear.startDate ) - , ( "end_date", Encode.string schoolYear.endDate ) - ] - , expect = Http.expectWhatever SchoolYearCreated - , timeout = Nothing - , tracker = Nothing - } - - -activateSchoolYear : String -> Int -> Cmd Msg -activateSchoolYear token id = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate" - , body = Http.emptyBody - , expect = Http.expectWhatever SchoolYearActivated - , timeout = Nothing - , tracker = Nothing - } - - -deleteSchoolYear : String -> Int -> Cmd Msg -deleteSchoolYear token id = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years/" ++ String.fromInt id - , body = Http.emptyBody - , expect = Http.expectWhatever SchoolYearDeleted - , timeout = Nothing - , tracker = Nothing - } diff --git a/frontend/src/Api/TimeEntry.elm b/frontend/src/Api/TimeEntry.elm deleted file mode 100644 index c1ebede..0000000 --- a/frontend/src/Api/TimeEntry.elm +++ /dev/null @@ -1,201 +0,0 @@ -module Api.TimeEntry exposing - ( checkWeekHasEntries - , createAdminTimeEntry - , deleteTimeEntry - , deleteWeekEntries - , downloadYearlySummaryPDF - , fetchAllTimeEntries - , fetchMyTimeEntries - , fetchWeekDates - , fetchWeeklyHours - , fetchYearlyHoursSummary - , updateTimeEntry - ) - -import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder) -import Bytes exposing (Bytes) -import Http -import Json.Decode as Decode exposing (bool, field) -import Json.Encode as Encode -import Types.Model exposing (AdminManualEntry, EditingTimeEntry) -import Types.Msg exposing (Msg(..)) - - -fetchMyTimeEntries : String -> Cmd Msg -fetchMyTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries" - , body = Http.emptyBody - , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -fetchAllTimeEntries : String -> Cmd Msg -fetchAllTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries" - , body = Http.emptyBody - , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -fetchWeekDates : String -> Int -> Int -> Cmd Msg -fetchWeekDates token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekDatesReceived weekDatesDecoder - , timeout = Nothing - , tracker = Nothing - } - - -checkWeekHasEntries : String -> Int -> Int -> Cmd Msg -checkWeekHasEntries token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) - , timeout = Nothing - , tracker = Nothing - } - - -deleteWeekEntries : String -> Int -> Int -> Cmd Msg -deleteWeekEntries token year week = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectWhatever WeekEntriesDeleted - , timeout = Nothing - , tracker = Nothing - } - - -updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg -updateTimeEntry token entry = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId - , body = - Http.jsonBody <| - Encode.object - [ ( "date", Encode.string entry.date ) - , ( "start_time", Encode.string entry.startTime ) - , ( "end_time", Encode.string entry.endTime ) - , ( "type", Encode.string entry.entryType ) - ] - , expect = Http.expectWhatever TimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - - -deleteTimeEntry : String -> Int -> Cmd Msg -deleteTimeEntry token entryId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entryId - , body = Http.emptyBody - , expect = Http.expectWhatever TimeEntryDeleted - , timeout = Nothing - , tracker = Nothing - } - - -createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg -createAdminTimeEntry token entry = - case entry.selectedUserId of - Just userId -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entry" - , body = - Http.jsonBody <| - Encode.object - [ ( "user_id", Encode.int userId ) - , ( "date", Encode.string entry.date ) - , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) - , ( "type", Encode.string "manual" ) - ] - , expect = Http.expectWhatever AdminTimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -fetchYearlyHoursSummary : String -> Cmd Msg -fetchYearlyHoursSummary token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/yearly-hours-summary" - , body = Http.emptyBody - , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -downloadYearlySummaryPDF : String -> Cmd Msg -downloadYearlySummaryPDF token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/yearly-summary/pdf" - , body = Http.emptyBody - , expect = - Http.expectBytesResponse YearlySummaryPDFReceived - (\response -> - case response of - Http.GoodStatus_ _ body -> - Ok body - - Http.BadUrl_ url -> - Err (Http.BadUrl url) - - Http.Timeout_ -> - Err Http.Timeout - - Http.NetworkError_ -> - Err Http.NetworkError - - Http.BadStatus_ metadata _ -> - Err (Http.BadStatus metadata.statusCode) - ) - , timeout = Nothing - , tracker = Nothing - } - - -fetchWeeklyHours : String -> Cmd Msg -fetchWeeklyHours token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/weekly-hours" - , body = Http.emptyBody - , expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder) - , timeout = Nothing - , tracker = Nothing - } diff --git a/frontend/src/Api/User.elm b/frontend/src/Api/User.elm deleted file mode 100644 index 17c77ac..0000000 --- a/frontend/src/Api/User.elm +++ /dev/null @@ -1,110 +0,0 @@ -module Api.User exposing - ( createUser - , deleteUser - , fetchMyInfo - , fetchUsers - , resetUserPassword - , updateUserWorkHours - ) - -import Api.Decoders exposing (userDecoder) -import Http -import Json.Decode as Decode -import Json.Encode as Encode -import Types.Model exposing (NewUser) -import Types.Msg exposing (Msg(..)) - - -fetchUsers : String -> Cmd Msg -fetchUsers token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/list" - , body = Http.emptyBody - , expect = Http.expectJson UsersReceived (Decode.list userDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -fetchMyInfo : String -> Cmd Msg -fetchMyInfo token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-info" - , body = Http.emptyBody - , expect = Http.expectJson MyInfoReceived userDecoder - , timeout = Nothing - , tracker = Nothing - } - - -createUser : String -> NewUser -> Cmd Msg -createUser token user = - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users" - , body = - Http.jsonBody <| - Encode.object - [ ( "username", Encode.string user.username ) - , ( "password", Encode.string user.password ) - , ( "is_admin", Encode.bool user.isAdmin ) - ] - , expect = Http.expectWhatever UserCreated - , timeout = Nothing - , tracker = Nothing - } - - -deleteUser : String -> Int -> Cmd Msg -deleteUser token userId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/delete?id=" ++ String.fromInt userId - , body = Http.emptyBody - , expect = Http.expectWhatever UserDeleted - , timeout = Nothing - , tracker = Nothing - } - - -updateUserWorkHours : String -> Int -> String -> Cmd Msg -updateUserWorkHours token userId hours = - case String.toFloat hours of - Just workHours -> - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId - , body = - Http.jsonBody <| - Encode.object - [ ( "yearly_hours", Encode.float workHours ) ] - , expect = Http.expectWhatever UserWorkHoursSaved - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -resetUserPassword : String -> Int -> String -> Cmd Msg -resetUserPassword token userId newPassword = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" - , body = - Http.jsonBody <| - Encode.object - [ ( "new_password", Encode.string newPassword ) ] - , expect = Http.expectWhatever ResetPasswordSaved - , timeout = Nothing - , tracker = Nothing - } diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 6f29eab..db45a90 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,20 +1,33 @@ -module Main exposing (..) +port module Main exposing (..) -import Api.Auth exposing (..) -import Api.Decoders exposing (..) -import Api.Schedule exposing (..) -import Api.SchoolYear exposing (..) -import Api.TimeEntry exposing (..) -import Api.User exposing (..) import Browser +import Bytes exposing (Bytes) +import Dict exposing (Dict) +import File.Download +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Http +import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) +import Json.Encode as Encode import Task import Time -import Types.Model exposing (..) -import Types.Msg exposing (Msg(..)) -import Types.Page exposing (..) -import Update.Update exposing (update) -import Utils.Ports exposing (..) -import View.View exposing (view) + + + +-- PORTS + + +port saveToken : Encode.Value -> Cmd msg + + +port removeToken : () -> Cmd msg + + +port confirmDelete : String -> Cmd msg + + +port confirmDeleteResponse : (Bool -> msg) -> Sub msg @@ -31,6 +44,205 @@ main = } + +-- FLAGS + + +type alias Flags = + { token : Maybe String + , isAdmin : Bool + } + + + +-- MODEL + + +type alias Model = + { page : Page + , activeTab : AdminTab + , username : String + , password : String + , token : Maybe String + , isAdmin : Bool + , schedules : List Schedule + , users : List User + , timeEntries : List TimeEntry + , weeklyHours : List WeeklyHours + , 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 + } + + +type Page + = LoginPage + | UserDashboard + | AdminDashboard + + +type AdminTab + = ScheduleTab + | UsersTab + | TimeEntriesTab + | SchoolYearsTab + + +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 + } + + init : Flags -> ( Model, Cmd Msg ) init flags = let @@ -87,8 +299,6 @@ init flags = , newSchoolYear = NewSchoolYear "" "" "" , activeSchoolYear = Nothing , editingSchoolYearId = Nothing - , toasts = [] - , nextToastId = 0 } cmd = @@ -99,11 +309,7 @@ init flags = , fetchSchedules (Just token) , fetchYearlyHoursSummary token , if flags.isAdmin then - Cmd.batch - [ fetchSchoolYears token - , fetchUsers token - , fetchAllTimeEntries token - ] + fetchSchoolYears token else fetchMyInfo token @@ -116,9 +322,3992 @@ init flags = +-- UPDATE + + +type Msg + = UpdateUsername String + | UpdatePassword String + | Login + | LoginResponse (Result Http.Error LoginResult) + | Logout + | SetTime Time.Posix + | FetchSchedules + | SchedulesReceived (Result Http.Error (List Schedule)) + | ToggleScheduleSelection Int Int + | SaveTimeEntries + | TimeEntriesSaved (Result Http.Error ()) + | PreviousWeek + | NextWeek + | EnableEditMode + | DisableEditMode + | DeleteWeekEntries + | WeekEntriesDeleted (Result Http.Error ()) + | SwitchTab AdminTab + | UpdateNewScheduleDay String + | UpdateNewScheduleStart String + | UpdateNewScheduleEnd String + | UpdateNewScheduleType String + | UpdateNewScheduleTitle String + | CreateSchedule + | ScheduleCreated (Result Http.Error ()) + | DeleteSchedule Int + | ScheduleDeleted (Result Http.Error ()) + | UpdateNewUsername String + | UpdateNewPassword String + | UpdateNewUserAdmin Bool + | CreateUser + | UserCreated (Result Http.Error ()) + | DeleteUser Int + | UserDeleted (Result Http.Error ()) + | FetchUsers + | UsersReceived (Result Http.Error (List User)) + | FetchMyTimeEntries + | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchAllTimeEntries + | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchWeeklyHours + | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) + | 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.Bytes) + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + ToggleMobileMenu -> + ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) + + CloseMobileMenu -> + ( { model | mobileMenuOpen = False }, Cmd.none ) + + UpdateUsername username -> + ( { model | username = username }, Cmd.none ) + + UpdatePassword password -> + ( { model | password = password }, Cmd.none ) + + Login -> + if model.isProcessing then + ( model, Cmd.none ) + + else + ( { model | isProcessing = True }, loginRequest model.username model.password ) + + LoginResponse (Ok result) -> + let + newPage = + if result.isAdmin then + AdminDashboard + + else + UserDashboard + + ( year, week ) = + getISOWeekFromPosix model.currentTime + + tokenData = + Encode.object + [ ( "token", Encode.string result.token ) + , ( "isAdmin", Encode.bool result.isAdmin ) + ] + in + ( { model + | token = Just result.token + , username = result.username + , isAdmin = result.isAdmin + , page = newPage + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ saveToken tokenData + , fetchSchedules (Just result.token) + , if not result.isAdmin then + Cmd.batch + [ fetchMyTimeEntries result.token + , fetchWeekDates result.token year week + , checkWeekHasEntries result.token year week + , fetchYearlyHoursSummary result.token + , fetchMyInfo result.token + ] + + else + Cmd.batch + [ fetchMyTimeEntries result.token + , fetchWeekDates result.token year week + , checkWeekHasEntries result.token year week + , fetchYearlyHoursSummary result.token + ] + ] + ) + + LoginResponse (Err _) -> + ( { model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none ) + + Logout -> + ( { model + | page = LoginPage + , token = Nothing + , isAdmin = False + , username = "" + , password = "" + , isProcessing = False + } + , removeToken () + ) + + FetchSchedules -> + ( model, fetchSchedules model.token ) + + SchedulesReceived (Ok schedules) -> + ( { model | schedules = schedules }, Cmd.none ) + + SchedulesReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none ) + + ToggleScheduleSelection scheduleId dayOfWeek -> + let + entry = + { scheduleId = scheduleId, dayOfWeek = dayOfWeek } + + newSelected = + if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then + List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries + + else + entry :: model.selectedEntries + in + ( { model | selectedEntries = newSelected }, Cmd.none ) + + SaveTimeEntries -> + case model.token of + Just token -> + ( { model | error = Nothing } + , saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates + ) + + Nothing -> + ( model, Cmd.none ) + + TimeEntriesSaved (Ok _) -> + case model.token of + Just token -> + ( { model + | error = Nothing + , weekEditMode = False + , hasEntriesForCurrentWeek = True + } + , Cmd.batch + [ fetchMyTimeEntries token + ] + ) + + Nothing -> + ( model, Cmd.none ) + + TimeEntriesSaved (Err _) -> + ( { model | error = Just "Fehler beim Speichern" }, Cmd.none ) + + PreviousWeek -> + let + ( newYear, newWeek ) = + previousWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ fetchWeekDates token newYear newWeek + , checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + NextWeek -> + let + ( newYear, newWeek ) = + nextWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ fetchWeekDates token newYear newWeek + , checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + FetchWeekDates -> + case model.token of + Just token -> + ( model, fetchWeekDates token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekDatesReceived (Ok weekDates) -> + ( { model | weekDates = Just weekDates }, Cmd.none ) + + WeekDatesReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none ) + + CheckWeekHasEntries -> + case model.token of + Just token -> + ( model, checkWeekHasEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekHasEntriesReceived (Ok hasEntries) -> + ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) + + WeekHasEntriesReceived (Err _) -> + ( model, Cmd.none ) + + SetTime time -> + let + ( year, week ) = + getISOWeekFromPosix time + + cmds = + case model.token of + Just token -> + if model.page == UserDashboard || model.page == LoginPage then + Cmd.batch + [ checkWeekHasEntries token year week + , fetchWeekDates token year week + , fetchMyTimeEntries token + ] + + else + Cmd.none + + Nothing -> + Cmd.none + in + ( { model + | currentTime = time + , currentWeek = week + , currentYear = year + } + , cmds + ) + + EnableEditMode -> + let + currentWeekEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + + preSelectedEntries = + List.map + (\entry -> + let + parts = + String.split "-" entry.date + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + dayOfWeek = + getDayOfWeek year month day + in + { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } + ) + currentWeekEntries + in + ( { model + | weekEditMode = True + , selectedEntries = preSelectedEntries + } + , Cmd.none + ) + + DisableEditMode -> + ( { model + | weekEditMode = False + } + , Cmd.none + ) + + DeleteWeekEntries -> + case model.token of + Just token -> + ( model, deleteWeekEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekEntriesDeleted (Ok _) -> + case model.token of + Just token -> + ( { model + | weekEditMode = True + , selectedEntries = [] + , hasEntriesForCurrentWeek = False + } + , fetchMyTimeEntries token + ) + + Nothing -> + ( model, Cmd.none ) + + WeekEntriesDeleted (Err _) -> + ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + + SwitchTab tab -> + let + cmd = + case tab of + UsersTab -> + case model.token of + Just token -> + fetchUsers token + + Nothing -> + Cmd.none + + TimeEntriesTab -> + case model.token of + Just token -> + Cmd.batch + [ fetchAllTimeEntries token + , fetchYearlyHoursSummary token + ] + + Nothing -> + Cmd.none + + SchoolYearsTab -> + case model.token of + Just token -> + Cmd.batch + [ fetchSchoolYears token + , fetchActiveSchoolYear token + ] + + Nothing -> + Cmd.none + + _ -> + Cmd.none + in + ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) + + UpdateNewScheduleDay day -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | dayOfWeek = day } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleStart time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | startTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleEnd time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | endTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleType scheduleType -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | scheduleType = scheduleType } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleTitle title -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | title = title } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + CreateSchedule -> + if + String.isEmpty model.newSchedule.dayOfWeek + || String.isEmpty model.newSchedule.startTime + || String.isEmpty model.newSchedule.endTime + then + ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, createSchedule token model.newSchedule ) + + Nothing -> + ( model, Cmd.none ) + + ScheduleCreated (Ok _) -> + case model.token of + Just token -> + let + emptySchedule = + NewSchedule "" "" "" "lesson" "" + in + ( { model + | newSchedule = emptySchedule + , error = Nothing + , isProcessing = False + } + , fetchSchedules model.token + ) + + Nothing -> + ( model, Cmd.none ) + + ScheduleCreated (Err err) -> + let + errorMsg = + case err of + Http.BadStatus 400 -> + "Ungültige Eingabe" + + Http.BadStatus 409 -> + "Dieser Stundenplan existiert bereits" + + Http.Timeout -> + "Anfrage abgelaufen" + + Http.NetworkError -> + "Netzwerkfehler" + + _ -> + "Fehler beim Erstellen" + in + ( { model + | error = Just errorMsg + , isProcessing = False + } + , Cmd.none + ) + + DeleteSchedule scheduleId -> + case model.token of + Just token -> + ( model, deleteSchedule token scheduleId ) + + Nothing -> + ( model, Cmd.none ) + + ScheduleDeleted (Ok _) -> + case model.token of + Just token -> + ( { model | error = Nothing }, fetchSchedules (Just token) ) + + Nothing -> + ( model, Cmd.none ) + + ScheduleDeleted (Err _) -> + ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + + UpdateNewUsername username -> + let + oldUser = + model.newUser + + newUser = + { oldUser | username = username } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewPassword password -> + let + oldUser = + model.newUser + + newUser = + { oldUser | password = password } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewUserAdmin isAdmin -> + let + oldUser = + model.newUser + + newUser = + { oldUser | isAdmin = isAdmin } + in + ( { model | newUser = newUser }, Cmd.none ) + + CreateUser -> + case model.token of + Just token -> + ( model, createUser token model.newUser ) + + Nothing -> + ( model, Cmd.none ) + + UserCreated (Ok _) -> + let + emptyUser = + NewUser "" "" False + in + case model.token of + Just token -> + ( { model | newUser = emptyUser }, fetchUsers token ) + + Nothing -> + ( model, Cmd.none ) + + UserCreated (Err _) -> + ( { model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none ) + + DeleteUser userId -> + case model.token of + Just token -> + ( model, deleteUser token userId ) + + Nothing -> + ( model, Cmd.none ) + + UserDeleted (Ok _) -> + case model.token of + Just token -> + ( { model + | pendingDeleteId = Nothing + , error = Nothing + , editingUserId = Nothing + , resetPasswordUserId = Nothing + } + , fetchUsers token + ) + + Nothing -> + ( model, Cmd.none ) + + UserDeleted (Err _) -> + ( { model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing }, Cmd.none ) + + FetchUsers -> + case model.token of + Just token -> + ( model, fetchUsers token ) + + Nothing -> + ( model, Cmd.none ) + + UsersReceived (Ok users) -> + ( { model | users = users }, Cmd.none ) + + UsersReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none ) + + FetchMyTimeEntries -> + case model.token of + Just token -> + ( model, fetchMyTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + MyTimeEntriesReceived (Ok entries) -> + let + hasEntries = + List.any + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + entries + in + ( { model + | timeEntries = entries + , hasEntriesForCurrentWeek = hasEntries + , weekEditMode = False + } + , Cmd.none + ) + + MyTimeEntriesReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none ) + + FetchAllTimeEntries -> + case model.token of + Just token -> + ( model, fetchAllTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + AllTimeEntriesReceived (Ok entries) -> + ( { model | timeEntries = entries }, Cmd.none ) + + AllTimeEntriesReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none ) + + FetchWeeklyHours -> + case model.token of + Just token -> + ( model, fetchWeeklyHours token ) + + Nothing -> + ( model, Cmd.none ) + + WeeklyHoursReceived (Ok hours) -> + ( { model | weeklyHours = hours }, Cmd.none ) + + WeeklyHoursReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none ) + + FetchYearlyHoursSummary -> + case model.token of + Just token -> + ( model, fetchYearlyHoursSummary token ) + + Nothing -> + ( model, Cmd.none ) + + YearlyHoursSummaryReceived (Ok summary) -> + ( { model | yearlyHoursSummary = summary }, Cmd.none ) + + YearlyHoursSummaryReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Jahresübersicht" }, Cmd.none ) + + MyWeeklySummaryReceived (Ok summary) -> + ( { model | userWeeklySummary = Just summary }, Cmd.none ) + + MyWeeklySummaryReceived (Err _) -> + ( { model | userWeeklySummary = Nothing }, Cmd.none ) + + EditTimeEntry entryId -> + case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of + Just entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = + { entryId = entryId + , date = entry.date + , startTime = entry.startTime + , endTime = entry.endTime + , entryType = entry.entryType + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + CancelEditTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + } + , Cmd.none + ) + + UpdateEditTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditTimeEntry -> + case model.token of + Just token -> + ( model, updateTimeEntry token model.editingTimeEntry ) + + Nothing -> + ( model, Cmd.none ) + + TimeEntryDeleted (Ok _) -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ fetchAllTimeEntries token + , fetchWeeklyHours token + , fetchYearlyHoursSummary token + ] + ) + + Nothing -> + ( model, Cmd.none ) + + TimeEntryDeleted (Err _) -> + ( { model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing }, Cmd.none ) + + EditUserWorkHours userId -> + case List.filter (\u -> u.id == userId) model.users |> List.head of + Just user -> + ( { model + | editingUserId = Just userId + , editingUserWorkHours = String.fromFloat user.yearlyWorkHours + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + CancelEditUserWorkHours -> + ( { model + | editingUserId = Nothing + , editingUserWorkHours = "" + } + , Cmd.none + ) + + UpdateEditUserWorkHours hours -> + ( { model | editingUserWorkHours = hours }, Cmd.none ) + + ResetUserPassword userId -> + ( { model + | resetPasswordUserId = Just userId + , resetPasswordNew = "" + } + , Cmd.none + ) + + CancelResetPassword -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + } + , Cmd.none + ) + + UpdateResetPasswordNew password -> + ( { model | resetPasswordNew = password }, Cmd.none ) + + SaveResetPassword -> + case model.resetPasswordUserId of + Just userId -> + case model.token of + Just token -> + ( model, resetUserPassword token userId model.resetPasswordNew ) + + Nothing -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + ResetPasswordSaved (Ok _) -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + , error = Just "Passwort erfolgreich zurückgesetzt" + } + , case model.token of + Just token -> + fetchUsers token + + Nothing -> + Cmd.none + ) + + ResetPasswordSaved (Err _) -> + ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + + StartEditingTimeEntry entryId entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType + } + , Cmd.none + ) + + CancelEditingTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + } + , Cmd.none + ) + + UpdateEditingTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditingTimeEntry -> + case ( model.token, model.editingTimeEntryId ) of + ( Just token, Just entryId ) -> + ( model, updateTimeEntry token model.editingTimeEntry ) + + _ -> + ( model, Cmd.none ) + + TimeEntrySaved (Ok _) -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , pendingDeleteId = Nothing + , error = Nothing + } + , fetchAllTimeEntries token + ) + + Nothing -> + ( model, Cmd.none ) + + TimeEntrySaved (Err _) -> + ( { model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none ) + + ConfirmDeleteTimeEntry entryId -> + ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) + + ConfirmDeleteUser userId -> + ( { model | pendingDeleteId = Just userId }, confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) + + DeleteConfirmed confirmed -> + if confirmed then + case ( model.token, model.pendingDeleteId ) of + ( Just token, Just id ) -> + let + isTimeEntry = + List.any (\e -> e.id == id) model.timeEntries + in + if isTimeEntry then + ( model, deleteTimeEntry token id ) + + else + ( model, deleteUser token id ) + + _ -> + ( model, Cmd.none ) + + else + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + SelectUserForManagement userId -> + ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) + + UpdateUserWorkHours input -> + ( { model | userWorkHoursInput = input }, Cmd.none ) + + SaveUserWorkHours -> + case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of + ( Just token, Just userId, Just hours ) -> + ( model, updateUserWorkHours token userId (String.fromFloat hours) ) + + _ -> + ( { model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none ) + + UserWorkHoursSaved (Ok _) -> + case model.token of + Just token -> + ( { model + | editingUserWorkHours = "" + , editingUserId = Nothing + , error = Nothing + } + , fetchUsers token + ) + + Nothing -> + ( model, Cmd.none ) + + UserWorkHoursSaved (Err _) -> + ( { model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none ) + + UpdateUserPassword input -> + ( { model | userPasswordInput = input }, Cmd.none ) + + SaveUserPassword -> + case ( model.token, model.selectedUserId ) of + ( Just token, Just userId ) -> + if String.length model.userPasswordInput > 0 then + ( model, resetUserPassword token userId model.userPasswordInput ) + + else + ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + + _ -> + ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + + UserPasswordSaved (Ok _) -> + ( { model + | userPasswordInput = "" + , selectedUserId = Nothing + , error = Nothing + } + , Cmd.none + ) + + UserPasswordSaved (Err _) -> + ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + + 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 -> + case model.token of + Just token -> + ( { model | isProcessing = True }, createAdminTimeEntry token model.adminManualEntryForm ) + + Nothing -> + ( model, Cmd.none ) + + AdminTimeEntrySaved (Ok _) -> + case model.token of + Just token -> + ( { model + | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ fetchAllTimeEntries token + , fetchYearlyHoursSummary token + , fetchWeeklyHours token + ] + ) + + Nothing -> + ( model, Cmd.none ) + + AdminTimeEntrySaved (Err _) -> + ( { model | error = Just "Fehler beim Erstellen des Eintrags", isProcessing = False }, Cmd.none ) + + FetchMyInfo -> + case model.token of + Just token -> + ( model, fetchMyInfo token ) + + Nothing -> + ( model, Cmd.none ) + + MyInfoReceived (Ok user) -> + ( { model | users = [ user ] }, Cmd.none ) + + MyInfoReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden deiner Daten" }, Cmd.none ) + + FetchSchoolYears -> + case model.token of + Just token -> + ( model, fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearsReceived (Ok years) -> + ( { model | schoolYears = years }, Cmd.none ) + + SchoolYearsReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Schuljahre" }, Cmd.none ) + + FetchActiveSchoolYear -> + case model.token of + Just token -> + ( model, fetchActiveSchoolYear token ) + + Nothing -> + ( model, Cmd.none ) + + ActiveSchoolYearReceived (Ok year) -> + ( { model | activeSchoolYear = Just year }, Cmd.none ) + + ActiveSchoolYearReceived (Err _) -> + ( { model | activeSchoolYear = Nothing }, Cmd.none ) + + 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 -> + if + String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + then + ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, createSchoolYear token model.newSchoolYear ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearCreated (Ok _) -> + case model.token of + Just token -> + ( { model + | newSchoolYear = NewSchoolYear "" "" "" + , error = Nothing + , isProcessing = False + } + , fetchSchoolYears token + ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearCreated (Err _) -> + ( { model + | error = Just "Fehler beim Erstellen des Schuljahres" + , isProcessing = False + } + , Cmd.none + ) + + ActivateSchoolYear id -> + case model.token of + Just token -> + ( model, activateSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearActivated (Ok _) -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ fetchSchoolYears token + , fetchActiveSchoolYear token + ] + ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearActivated (Err _) -> + ( { model | error = Just "Fehler beim Aktivieren" }, Cmd.none ) + + DeleteSchoolYear id -> + case model.token of + Just token -> + ( model, deleteSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearDeleted (Ok _) -> + case model.token of + Just token -> + ( { model | error = Nothing }, fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearDeleted (Err _) -> + ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + + DownloadYearlySummaryPDF -> + case model.token of + Just token -> + ( { model | isProcessing = True }, downloadYearlySummaryPDF token ) + + Nothing -> + ( model, Cmd.none ) + + YearlySummaryPDFReceived (Ok pdfBytes) -> + let + filename = + "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" + in + ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) + + YearlySummaryPDFReceived (Err _) -> + ( { model + | error = Just "Fehler beim Herunterladen der PDF" + , isProcessing = False + } + , Cmd.none + ) + + + -- SUBSCRIPTIONS subscriptions : Model -> Sub Msg subscriptions model = confirmDeleteResponse DeleteConfirmed + + + +-- HELPER FUNCTIONS + + +getISOWeekFromPosix : Time.Posix -> ( Int, Int ) +getISOWeekFromPosix time = + let + year = + Time.toYear Time.utc time + + month = + Time.toMonth Time.utc time |> monthToInt + + day = + Time.toDay Time.utc time + in + ( year, getISOWeek year month day ) + + +monthToInt : Time.Month -> Int +monthToInt month = + case month of + Time.Jan -> + 1 + + Time.Feb -> + 2 + + Time.Mar -> + 3 + + Time.Apr -> + 4 + + Time.May -> + 5 + + Time.Jun -> + 6 + + Time.Jul -> + 7 + + Time.Aug -> + 8 + + Time.Sep -> + 9 + + Time.Oct -> + 10 + + Time.Nov -> + 11 + + Time.Dec -> + 12 + + +getISOWeek : Int -> Int -> Int -> Int +getISOWeek year month day = + let + dayOfYear = + getDayOfYear year month day + + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1DayOfYear = + 4 - jan4DayOfWeek + + weekNum = + ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 + in + if weekNum < 1 then + 52 + + else if weekNum > 52 then + let + dec31DayOfWeek = + getDayOfWeek year 12 31 + + jan1DayOfWeek = + getDayOfWeek year 1 1 + in + if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then + weekNum + + else + 1 + + else + weekNum + + +getDayOfYear : Int -> Int -> Int -> Int +getDayOfYear year month day = + let + daysInMonth = + [ 31 + , if isLeapYear year then + 29 + + else + 28 + , 31 + , 30 + , 31 + , 30 + , 31 + , 31 + , 30 + , 31 + , 30 + , 31 + ] + + daysBefore = + List.take (month - 1) daysInMonth |> List.sum + in + daysBefore + day + + +isLeapYear : Int -> Bool +isLeapYear year = + (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) + + +getDayOfWeek : Int -> Int -> Int -> Int +getDayOfWeek year month day = + let + adjustedMonth = + if month < 3 then + month + 12 + + else + month + + adjustedYear = + if month < 3 then + year - 1 + + else + year + + q = + day + + m = + adjustedMonth + + k = + modBy 100 adjustedYear + + j = + adjustedYear // 100 + + h = + (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 + in + (h + 5) |> modBy 7 + + +getDateForWeekDay : Int -> Int -> Int -> String +getDateForWeekDay year week dayOfWeek = + let + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1Date = + 4 - jan4DayOfWeek + + targetDayOfYear = + mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek + + ( finalYear, finalMonth, finalDay ) = + if targetDayOfYear < 1 then + addDaysToDate (year - 1) 12 31 targetDayOfYear + + else + addDaysToDate year 1 targetDayOfYear 0 + in + String.fromInt finalYear + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalMonth) + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalDay) + + +addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) +addDaysToDate startYear startMonth startDay daysToAdd = + let + daysInMonth m y = + case m of + 1 -> + 31 + + 2 -> + if isLeapYear y then + 29 + + else + 28 + + 3 -> + 31 + + 4 -> + 30 + + 5 -> + 31 + + 6 -> + 30 + + 7 -> + 31 + + 8 -> + 31 + + 9 -> + 30 + + 10 -> + 31 + + 11 -> + 30 + + 12 -> + 31 + + _ -> + 0 + + helper y m d remaining = + if remaining == 0 then + ( y, m, d ) + + else if remaining > 0 then + let + daysInCurrentMonth = + daysInMonth m y + + daysLeftInMonth = + daysInCurrentMonth - d + in + if remaining <= daysLeftInMonth then + ( y, m, d + remaining ) + + else if m == 12 then + helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) + + else + helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) + + else if d + remaining >= 1 then + ( y, m, d + remaining ) + + else if m == 1 then + let + prevMonthDays = + daysInMonth 12 (y - 1) + in + helper (y - 1) 12 prevMonthDays (remaining + d) + + else + let + prevMonthDays = + daysInMonth (m - 1) y + in + helper y (m - 1) prevMonthDays (remaining + d) + in + helper startYear startMonth startDay daysToAdd + + +previousWeek : Int -> Int -> ( Int, Int ) +previousWeek year week = + if week == 1 then + ( year - 1, 52 ) + + else + ( year, week - 1 ) + + +nextWeek : Int -> Int -> ( Int, Int ) +nextWeek year week = + if week >= 52 then + ( year + 1, 1 ) + + else + ( year, week + 1 ) + + +getWeekDateRange : Int -> Int -> String +getWeekDateRange year week = + let + mondayDate = + getDateForWeekDay year week 0 + + fridayDate = + getDateForWeekDay year week 4 + in + mondayDate ++ " bis " ++ fridayDate + + +getYearWeekFromDate : String -> ( Int, Int ) +getYearWeekFromDate dateStr = + let + parts = + String.split "-" dateStr + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + in + ( year, getISOWeek year month day ) + + +calculateHours : String -> String -> Float +calculateHours startTime endTime = + let + parseTime timeStr = + case String.split ":" timeStr of + [ h, m ] -> + (String.toFloat h |> Maybe.withDefault 0) + + ((String.toFloat m |> Maybe.withDefault 0) / 60) + + _ -> + 0 + + start = + parseTime startTime + + end = + parseTime endTime + in + if end > start then + end - start + + else if endTime == "manual" then + case String.toFloat startTime of + Just time -> + time + + Nothing -> + 0 + + else + 0 + + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model + + UserDashboard -> + viewUserDashboard model + + AdminDashboard -> + viewAdminDashboard model + ] + + +viewLogin : Model -> Html Msg +viewLogin model = + section [ class "section" ] + [ div [ class "container" ] + [ div [ class "columns is-centered" ] + [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] + [ div [ class "box" ] + [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] + , case model.error of + Just err -> + div [ class "notification is-danger" ] [ text err ] + + Nothing -> + text "" + , div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.username + , onInput UpdateUsername + ] + [] + ] + ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.password + , onInput UpdatePassword + ] + [] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-fullwidth" + , onClick Login + ] + [ text "Anmelden" ] + ] + ] + ] + ] + ] + ] + ] + + +viewUserDashboard : Model -> Html Msg +viewUserDashboard model = + div [] + [ nav [ class "navbar is-primary" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] + ] + , 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 + , case model.error of + Just err -> + div [ class "notification is-danger mt-4" ] [ text err ] + + Nothing -> + text "" + ] + ] + ] + + +viewAdminDashboard : Model -> Html Msg +viewAdminDashboard model = + div [] + [ nav [ class "navbar is-danger" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarAdmin" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ div [ class "tabs is-boxed" ] + [ ul [] + [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] + [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] + , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] + [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] + , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] + [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] + , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ] + [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ] + ] + ] + , case model.activeTab of + ScheduleTab -> + viewScheduleTab model + + UsersTab -> + viewUsersTab model + + TimeEntriesTab -> + viewTimeEntriesTab model + + SchoolYearsTab -> + viewSchoolYearsTab model + ] + ] + ] + + +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) ] + ] + + +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) + ] + + +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) + ] + + +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..." ] + ] + + +viewUserYearlyTotal : Model -> Html Msg +viewUserYearlyTotal model = + let + yearlyTotal = + model.timeEntries + |> List.map + (\entry -> + if entry.entryType == "lesson" then + 1.0 + + else + 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 ++ "%") ] + ] + + +viewScheduleTab : Model -> Html Msg +viewScheduleTab model = + div [] + [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] + , viewScheduleForm model + , viewScheduleList model + ] + + +viewUsersTab : Model -> Html Msg +viewUsersTab model = + div [] + [ h2 [ class "title" ] [ text "Benutzer verwalten" ] + , viewUserForm model + , viewUserList model + ] + + +viewTimeEntriesTab : Model -> Html Msg +viewTimeEntriesTab model = + div [] + [ h2 [ class "title" ] [ text "Jahresübersicht" ] + , viewYearlyHoursSummary model + , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] + , viewAdminManualEntryForm model + , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] + , case model.editingTimeEntryId of + Just _ -> + viewTimeEntriesEditForm model + + Nothing -> + viewTimeEntriesListWithEdit model + ] + + +viewYearlyHoursSummary : Model -> Html Msg +viewYearlyHoursSummary model = + div [ class "box" ] + [ div [ class "level mb-4" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ a + [ class "button is-info" + , onClick DownloadYearlySummaryPDF + , disabled model.isProcessing + ] + [ span [ class "icon" ] + [ i [ class "fas fa-file-pdf" ] [] ] + , span [] + [ text + (if model.isProcessing then + "Wird erstellt..." + + else + "PDF exportieren" + ) + ] + ] + ] + ] + ] + , if List.isEmpty model.yearlyHoursSummary then + p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] + , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] + , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] + , th [ class "has-text-centered" ] [ text "Status" ] + ] + ] + , tbody [] + (List.map viewYearlyHourRow model.yearlyHoursSummary) + ] + ] + + +viewYearlyHourRow : YearlyHoursSummary -> Html Msg +viewYearlyHourRow summary = + let + statusClass = + if summary.remainingYearly > 0 then + "has-text-danger" + + else if abs summary.remainingYearly < 0.5 then + "has-text-success" + + else + "has-text-warning" + in + tr [] + [ td [] [ text summary.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] + , td [ class ("has-text-centered " ++ statusClass) ] + [ if summary.remainingYearly > 0 then + text ("Offen: " ++ String.fromFloat summary.remainingYearly) + + else if summary.remainingYearly < -0.5 then + text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) + + else + text "✓ Erfüllt" + ] + ] + + +viewAdminManualEntryForm : Model -> Html Msg +viewAdminManualEntryForm model = + div [ class "box has-background-info-light" ] + [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] + , p [ class "help mb-3" ] + [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Mitarbeiter" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] + (option [ value "" ] [ text "-- Wählen --" ] + :: List.map + (\u -> + option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] + ) + model.users + ) + ] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.adminManualEntryForm.date + , onInput UpdateManualEntryDate + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "number" + , step "0.5" + , placeholder "z.B. 2.5 oder -1.0" + , value model.adminManualEntryForm.hours + , onInput UpdateManualEntryHours + ] + [] + ] + , p [ class "help" ] + [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-info" + , onClick SaveAdminTimeEntry + , disabled + (case model.adminManualEntryForm.selectedUserId of + Just _ -> + model.isProcessing || String.isEmpty model.adminManualEntryForm.hours + + Nothing -> + True + ) + ] + [ text "Eintrag erstellen" ] + ] + ] + ] + + +viewTimeEntriesEditForm : Model -> Html Msg +viewTimeEntriesEditForm model = + div [ class "box has-background-warning-light" ] + [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-success" + , onClick SaveEditTimeEntry + ] + [ text "Speichern" ] + ] + , div [ class "control" ] + [ button + [ class "button is-light" + , onClick CancelEditTimeEntry + ] + [ text "Abbrechen" ] + ] + ] + , viewTimeEntriesListWithEdit model + ] + + +viewTimeEntriesListWithEdit : Model -> Html Msg +viewTimeEntriesListWithEdit model = + div [ class "box" ] + [ if List.isEmpty model.timeEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) + ] + ] + + +viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithEdit model entry = + let + hours = + calculateHours entry.startTime entry.endTime + + isEditing = + model.editingTimeEntryId == Just entry.id + in + if isEditing then + tr [] + [ td [] [ text entry.username ] + , td [] + [ input + [ class "input is-small" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + , td [] + [ div [ class "field is-grouped" ] + [ div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + , div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , td [] + [ div [ class "select is-small" ] + [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + , td [ class "has-text-right" ] [ text "" ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditTimeEntry entry.id) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-small is-danger" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + + +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" ] [] ] + ] + ] + ] + ] + ] + + +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) + ] + + +viewScheduleForm : Model -> Html Msg +viewScheduleForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Wochentag" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleDay + , disabled model.isProcessing + , value model.newSchedule.dayOfWeek + ] + [ option [ value "" ] [ text "Wochentag wählen" ] + , option [ value "0" ] [ text "Montag" ] + , option [ value "1" ] [ text "Dienstag" ] + , option [ value "2" ] [ text "Mittwoch" ] + , option [ value "3" ] [ text "Donnerstag" ] + , option [ value "4" ] [ text "Freitag" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.startTime + , onInput UpdateNewScheduleStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.endTime + , onInput UpdateNewScheduleEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleType + , value model.newSchedule.scheduleType + , disabled model.isProcessing + ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Titel" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "z.B. Mathematik" + , value model.newSchedule.title + , onInput UpdateNewScheduleTitle + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchedule + , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Hinzufügen" + ] + ] + ] + , if String.isEmpty model.newSchedule.dayOfWeek then + div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] + + else + text "" + ] + + +viewScheduleList : Model -> Html Msg +viewScheduleList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Tag" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [] [ text "Titel" ] + , th [] [ text "Aktion" ] + ] + ] + , tbody [] + (List.map viewScheduleRow model.schedules) + ] + ] + + +viewScheduleRow : Schedule -> Html Msg +viewScheduleRow schedule = + let + dayName = + case schedule.dayOfWeek of + 0 -> + "Montag" + + 1 -> + "Dienstag" + + 2 -> + "Mittwoch" + + 3 -> + "Donnerstag" + + 4 -> + "Freitag" + + _ -> + "Unbekannt" + + typeName = + if schedule.scheduleType == "break" then + "Pause" + + else + "Unterricht" + in + tr [] + [ td [] [ text dayName ] + , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , td [] [ text typeName ] + , td [] [ text schedule.title ] + , td [] + [ button + [ class "button is-small is-danger" + , onClick (DeleteSchedule schedule.id) + ] + [ text "Löschen" ] + ] + ] + + +viewUserForm : Model -> Html Msg +viewUserForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.newUser.username + , onInput UpdateNewUsername + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.newUser.password + , onInput UpdateNewPassword + ] + [] + ] + ] + ] + , div [ class "column is-narrow" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Admin" ] + , div [ class "control" ] + [ label [ class "checkbox" ] + [ input + [ type_ "checkbox" + , checked model.newUser.isAdmin + , onCheck UpdateNewUserAdmin + ] + [] + , text " Admin-Rechte" + ] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] + ] + ] + ] + + +viewUserList : Model -> Html Msg +viewUserList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Benutzer" ] + , if List.isEmpty model.users then + p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "ID" ] + , th [] [ text "Benutzername" ] + , th [] [ text "Rolle" ] + , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewUserRowWithActions model) model.users) + ] + ] + + +viewUserRowWithActions : Model -> User -> Html Msg +viewUserRowWithActions model user = + if model.editingUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "number" + , step "0.5" + , value model.editingUserWorkHours + , onInput UpdateEditUserWorkHours + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] + ] + ] + + else if model.resetPasswordUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "password" + , placeholder "Neues Passwort" + , value model.resetPasswordNew + , onInput UpdateResetPasswordNew + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + div [] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditUserWorkHours user.id) + ] + [ text "Arbeitszeit" ] + , button + [ class "button is-small is-warning mr-2" + , onClick (ResetUserPassword user.id) + ] + [ text "PW Reset" ] + , button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewUserRow : User -> Html Msg +viewUserRow user = + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + + +viewWeeklyHoursSummary : Model -> Html Msg +viewWeeklyHoursSummary model = + let + filteredHours = + List.filter + (\h -> h.week == model.currentWeek && h.year == model.currentYear) + model.weeklyHours + in + div [ class "box" ] + [ if List.isEmpty filteredHours then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Arbeitet" ] + , th [ class "has-text-right" ] [ text "Soll" ] + , th [ class "has-text-right" ] [ text "Verbleibend" ] + , th [] [ text "Fortschritt" ] + ] + ] + , tbody [] + (List.map viewWeeklyHoursRow filteredHours) + , tfoot [] + [ tr [ class "has-background-light" ] + [ th [] [ text "Gesamt" ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] + , th [] [ text "" ] + , th [] [ text "" ] + ] + ] + ] + ] + + +viewWeeklyHoursRow : WeeklyHours -> Html Msg +viewWeeklyHoursRow hours = + let + progressPercent = + Basics.min 100 (hours.totalHours / hours.targetHours * 100) + + progressColor = + if hours.totalHours >= hours.targetHours then + "is-success" + + else if hours.totalHours >= hours.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + tr [] + [ td [] [ text hours.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] + , td [] + [ progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [] + ] + ] + + +viewTimeEntriesList : Model -> Html Msg +viewTimeEntriesList model = + let + filteredEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + in + div [ class "box" ] + [ if List.isEmpty filteredEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithActions model) filteredEntries) + ] + ] + + +viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithActions model entry = + let + hours = + if entry.entryType == "lesson" then + 1.0 + + else + calculateHours entry.startTime entry.endTime + in + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [] + [ div [ class "buttons are-small" ] + [ button + [ class "button is-info is-small" + , onClick (StartEditingTimeEntry entry.id entry) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-danger is-small" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewSchoolYearsTab : Model -> Html Msg +viewSchoolYearsTab model = + div [] + [ h2 [ class "title" ] [ text "Schuljahre verwalten" ] + , case model.activeSchoolYear of + Just schoolYear -> + div [ class "notification is-info is-light mb-4" ] + [ p [ class "has-text-weight-bold" ] + [ text ("Aktives Schuljahr: " ++ schoolYear.name) ] + , p [ class "is-size-7" ] + [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ] + ] + + Nothing -> + div [ class "notification is-warning is-light mb-4" ] + [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ] + , viewSchoolYearForm model + , viewSchoolYearsList model + ] + + +viewSchoolYearForm : Model -> Html Msg +viewSchoolYearForm model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "2024/2025" + , value model.newSchoolYear.name + , onInput UpdateNewSchoolYearName + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startdatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.startDate + , onInput UpdateNewSchoolYearStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Enddatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.endDate + , onInput UpdateNewSchoolYearEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchoolYear + , disabled + (String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + || model.isProcessing + ) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Schuljahr erstellen" + ] + ] + ] + ] + + +viewSchoolYearsList : Model -> Html Msg +viewSchoolYearsList model = + div [ class "box mt-4" ] + [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ] + , if List.isEmpty model.schoolYears then + p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Name" ] + , th [] [ text "Startdatum" ] + , th [] [ text "Enddatum" ] + , th [ class "has-text-centered" ] [ text "Status" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map viewSchoolYearRow model.schoolYears) + ] + ] + + +viewSchoolYearRow : SchoolYear -> Html Msg +viewSchoolYearRow schoolYear = + tr [] + [ td [] [ text schoolYear.name ] + , td [] [ text schoolYear.startDate ] + , td [] [ text schoolYear.endDate ] + , td [ class "has-text-centered" ] + [ if schoolYear.isActive then + span [ class "tag is-success" ] [ text "Aktiv" ] + + else + span [ class "tag is-light" ] [ text "Inaktiv" ] + ] + , td [ class "has-text-centered" ] + [ if not schoolYear.isActive then + button + [ class "button is-small is-info mr-2" + , onClick (ActivateSchoolYear schoolYear.id) + ] + [ text "Aktivieren" ] + + else + text "" + , button + [ class "button is-small is-danger" + , onClick (DeleteSchoolYear schoolYear.id) + ] + [ text "Löschen" ] + ] + ] + + + +-- HTTP + + +type alias LoginResult = + { token : String + , username : String + , isAdmin : Bool + } + + +loginRequest : String -> String -> Cmd Msg +loginRequest username password = + Http.post + { url = "/api/login" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string username ) + , ( "password", Encode.string password ) + ] + , expect = Http.expectJson LoginResponse loginDecoder + } + + +loginDecoder : Decoder LoginResult +loginDecoder = + Decode.map3 LoginResult + (field "token" string) + (field "username" string) + (field "is_admin" bool) + + +fetchSchedules : Maybe String -> Cmd Msg +fetchSchedules maybeToken = + case maybeToken of + Just token -> + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/schedules" + , body = Http.emptyBody + , expect = Http.expectJson SchedulesReceived (Decode.list scheduleDecoder) + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +scheduleDecoder : Decoder Schedule +scheduleDecoder = + Decode.map6 Schedule + (field "id" int) + (field "day_of_week" int) + (field "start_time" string) + (field "end_time" string) + (field "type" string) + (field "title" string) + + +fetchMyTimeEntries : String -> Cmd Msg +fetchMyTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries" + , body = Http.emptyBody + , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg +saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = + case maybeWeekDates of + Nothing -> + Cmd.none + + Just weekDates -> + let + getScheduleById id = + List.filter (\s -> s.id == id) schedules |> List.head + + getDateForDay dayOfWeek = + weekDates.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + + createEntryData entry = + case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of + ( Just schedule, Just dateStr ) -> + Just <| + Encode.object + [ ( "schedule_id", Encode.int entry.scheduleId ) + , ( "date", Encode.string dateStr ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + ] + + _ -> + Nothing + + entriesData = + List.filterMap createEntryData selectedEntries + in + if List.isEmpty entriesData then + Cmd.none + + else + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/time-entries/batch" + , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] + , expect = Http.expectWhatever TimeEntriesSaved + , timeout = Nothing + , tracker = Nothing + } + + +deleteWeekEntries : String -> Int -> Int -> Cmd Msg +deleteWeekEntries token year week = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectWhatever WeekEntriesDeleted + , timeout = Nothing + , tracker = Nothing + } + + +createSchedule : String -> NewSchedule -> Cmd Msg +createSchedule token schedule = + case String.toInt schedule.dayOfWeek of + Just day -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules" + , body = + Http.jsonBody <| + Encode.object + [ ( "day_of_week", Encode.int day ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "title", Encode.string schedule.title ) + ] + , expect = Http.expectWhatever ScheduleCreated + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +deleteSchedule : String -> Int -> Cmd Msg +deleteSchedule token scheduleId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId + , body = Http.emptyBody + , expect = Http.expectWhatever ScheduleDeleted + , timeout = Nothing + , tracker = Nothing + } + + +createUser : String -> NewUser -> Cmd Msg +createUser token user = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string user.username ) + , ( "password", Encode.string user.password ) + , ( "is_admin", Encode.bool user.isAdmin ) + ] + , expect = Http.expectWhatever UserCreated + , timeout = Nothing + , tracker = Nothing + } + + +deleteUser : String -> Int -> Cmd Msg +deleteUser token userId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/delete?id=" ++ String.fromInt userId + , body = Http.emptyBody + , expect = Http.expectWhatever UserDeleted + , timeout = Nothing + , tracker = Nothing + } + + +fetchUsers : String -> Cmd Msg +fetchUsers token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/list" + , body = Http.emptyBody + , expect = Http.expectJson UsersReceived (Decode.list userDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +userDecoder : Decoder User +userDecoder = + Decode.map4 User + (field "id" int) + (field "username" string) + (field "is_admin" bool) + (field "yearly_hours" float) + + +fetchAllTimeEntries : String -> Cmd Msg +fetchAllTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries" + , body = Http.emptyBody + , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +timeEntryDecoder : Decoder TimeEntry +timeEntryDecoder = + Decode.map8 TimeEntry + (field "id" int) + (field "user_id" int) + (field "schedule_id" int) + (field "date" string) + (field "type" string) + (field "username" string) + (field "start_time" string) + (field "end_time" string) + + +fetchWeeklyHours : String -> Cmd Msg +fetchWeeklyHours token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/weekly-hours" + , body = Http.emptyBody + , expect = Http.expectJson WeeklyHoursReceived (Decode.list weeklyHoursDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +weeklyHoursDecoder : Decoder WeeklyHours +weeklyHoursDecoder = + Decode.map7 WeeklyHours + (field "user_id" int) + (field "username" string) + (field "year" int) + (field "week" int) + (field "total_hours" float) + (field "expected_hours" float) + (field "remaining_hours" float) + + +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 + } + + +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)) + + +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 + + +fetchWeekDates : String -> Int -> Int -> Cmd Msg +fetchWeekDates token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekDatesReceived weekDatesDecoder + , timeout = Nothing + , tracker = Nothing + } + + +weekDatesDecoder : Decoder WeekDates +weekDatesDecoder = + Decode.map4 WeekDates + (field "year" int) + (field "week" int) + (field "dates" (Decode.dict string) |> Decode.map Dict.toList) + (field "range" string) + + +checkWeekHasEntries : String -> Int -> Int -> Cmd Msg +checkWeekHasEntries token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) + , timeout = Nothing + , tracker = Nothing + } + + +updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg +updateTimeEntry token entry = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId + , body = + Http.jsonBody <| + Encode.object + [ ( "date", Encode.string entry.date ) + , ( "start_time", Encode.string entry.startTime ) + , ( "end_time", Encode.string entry.endTime ) + , ( "type", Encode.string entry.entryType ) + ] + , expect = Http.expectWhatever TimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + +deleteTimeEntry : String -> Int -> Cmd Msg +deleteTimeEntry token entryId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entryId + , body = Http.emptyBody + , expect = Http.expectWhatever TimeEntryDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateUserWorkHours : String -> Int -> String -> Cmd Msg +updateUserWorkHours token userId hours = + case String.toFloat hours of + Just workHours -> + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId + , body = + Http.jsonBody <| + Encode.object + [ ( "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 + } + + +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 + } + + +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 + } + + +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) + + +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 + } diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm deleted file mode 100644 index aae29d0..0000000 --- a/frontend/src/Types/Api.elm +++ /dev/null @@ -1,17 +0,0 @@ -module Types.Api exposing - ( ApiError - , LoginResult - ) - - -type alias LoginResult = - { token : String - , username : String - , isAdmin : Bool - } - - -type alias ApiError = - { code : String - , message : String - } diff --git a/frontend/src/Types/Model.elm b/frontend/src/Types/Model.elm deleted file mode 100644 index 64911d6..0000000 --- a/frontend/src/Types/Model.elm +++ /dev/null @@ -1,218 +0,0 @@ -module Types.Model exposing - ( AdminManualEntry - , EditingTimeEntry - , Flags - , Model - , NewSchedule - , NewSchoolYear - , NewUser - , Schedule - , SchoolYear - , SelectedEntry - , TimeEntry - , Toast - , ToastType(..) - , User - , WeekDates - , WeeklyHours - , WeeklySummary - , YearlyHoursSummary - ) - -import Time -import Types.Page exposing (AdminTab, Page) - - -type alias Model = - { page : Page - , activeTab : AdminTab - , username : String - , password : String - , token : Maybe String - , isAdmin : Bool - , schedules : List Schedule - , users : List User - , timeEntries : List TimeEntry - , weeklyHours : List WeeklyHours - , yearlyHoursSummary : List YearlyHoursSummary - , selectedEntries : List SelectedEntry - , currentWeek : Int - , currentYear : Int - , weekDates : Maybe WeekDates - , currentTime : Time.Posix - , zone : Time.Zone - , newSchedule : NewSchedule - , newUser : NewUser - , error : Maybe String - , weekEditMode : Bool - , hasEntriesForCurrentWeek : Bool - , userWeeklySummary : Maybe WeeklySummary - , editingTimeEntryId : Maybe Int - , editingTimeEntry : EditingTimeEntry - , editingUserId : Maybe Int - , editingUserWorkHours : String - , resetPasswordUserId : Maybe Int - , resetPasswordNew : String - , pendingDeleteId : Maybe Int - , selectedUserId : Maybe Int - , userWorkHoursInput : String - , userPasswordInput : String - , isProcessing : Bool - , mobileMenuOpen : Bool - , adminManualEntryForm : AdminManualEntry - , schoolYears : List SchoolYear - , newSchoolYear : NewSchoolYear - , activeSchoolYear : Maybe SchoolYear - , editingSchoolYearId : Maybe Int - , toasts : List Toast - , nextToastId : Int - } - - -type ToastType - = ErrorToast - | SuccessToast - | InfoToast - | WarningToast - - -type alias Toast = - { id : Int - , message : String - , toastType : ToastType - , dismissible : Bool - } - - -type alias Flags = - { token : Maybe String - , isAdmin : Bool - } - - -type alias Schedule = - { id : Int - , dayOfWeek : Int - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - - -type alias User = - { id : Int - , username : String - , isAdmin : Bool - , yearlyWorkHours : Float - } - - -type alias TimeEntry = - { id : Int - , userId : Int - , scheduleId : Int - , date : String - , entryType : String - , username : String - , startTime : String - , endTime : String - } - - -type alias SelectedEntry = - { scheduleId : Int - , dayOfWeek : Int - } - - -type alias NewSchedule = - { dayOfWeek : String - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - - -type alias NewUser = - { username : String - , password : String - , isAdmin : Bool - } - - -type alias WeekDates = - { year : Int - , week : Int - , dates : List ( String, String ) - , range : String - } - - -type alias WeeklySummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - - -type alias EditingTimeEntry = - { entryId : Int - , date : String - , startTime : String - , endTime : String - , entryType : String - } - - -type alias WeeklyHours = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - - -type alias YearlyHoursSummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , yearlyTarget : Float - , yearlyActual : Float - , weeklyTarget : Float - , remainingYearly : Float - } - - -type alias AdminManualEntry = - { selectedUserId : Maybe Int - , date : String - , hours : String - , entryType : String - } - - -type alias SchoolYear = - { id : Int - , name : String - , startDate : String - , endDate : String - , isActive : Bool - } - - -type alias NewSchoolYear = - { name : String - , startDate : String - , endDate : String - } diff --git a/frontend/src/Types/Msg.elm b/frontend/src/Types/Msg.elm deleted file mode 100644 index 4158571..0000000 --- a/frontend/src/Types/Msg.elm +++ /dev/null @@ -1,133 +0,0 @@ -module Types.Msg exposing (Msg(..)) - -import Bytes exposing (Bytes) -import Http -import Time -import Types.Api exposing (LoginResult) -import Types.Model - exposing - ( Schedule - , SchoolYear - , TimeEntry - , ToastType(..) - , User - , WeekDates - , WeeklyHours - , WeeklySummary - , YearlyHoursSummary - ) -import Types.Page exposing (AdminTab) - - -type Msg - = UpdateUsername String - | UpdatePassword String - | Login - | LoginResponse (Result Http.Error LoginResult) - | Logout - | SetTime Time.Posix - | FetchSchedules - | SchedulesReceived (Result Http.Error (List Schedule)) - | ToggleScheduleSelection Int Int - | SaveTimeEntries - | TimeEntriesSaved (Result Http.Error ()) - | PreviousWeek - | NextWeek - | EnableEditMode - | DisableEditMode - | DeleteWeekEntries - | WeekEntriesDeleted (Result Http.Error ()) - | SwitchTab AdminTab - | UpdateNewScheduleDay String - | UpdateNewScheduleStart String - | UpdateNewScheduleEnd String - | UpdateNewScheduleType String - | UpdateNewScheduleTitle String - | CreateSchedule - | ScheduleCreated (Result Http.Error ()) - | DeleteSchedule Int - | ScheduleDeleted (Result Http.Error ()) - | UpdateNewUsername String - | UpdateNewPassword String - | UpdateNewUserAdmin Bool - | CreateUser - | UserCreated (Result Http.Error ()) - | DeleteUser Int - | UserDeleted (Result Http.Error ()) - | FetchUsers - | UsersReceived (Result Http.Error (List User)) - | FetchMyTimeEntries - | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchAllTimeEntries - | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchWeeklyHours - | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) - | FetchYearlyHoursSummary - | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary)) - | FetchWeekDates - | WeekDatesReceived (Result Http.Error WeekDates) - | CheckWeekHasEntries - | WeekHasEntriesReceived (Result Http.Error Bool) - | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) - | EditTimeEntry Int - | CancelEditTimeEntry - | UpdateEditTimeEntryDate String - | UpdateEditTimeEntryStartTime String - | UpdateEditTimeEntryEndTime String - | UpdateEditTimeEntryType String - | SaveEditTimeEntry - | TimeEntrySaved (Result Http.Error ()) - | TimeEntryDeleted (Result Http.Error ()) - | EditUserWorkHours Int - | CancelEditUserWorkHours - | UpdateEditUserWorkHours String - | SaveUserWorkHours - | UserWorkHoursSaved (Result Http.Error ()) - | ResetUserPassword Int - | CancelResetPassword - | UpdateResetPasswordNew String - | SaveResetPassword - | ResetPasswordSaved (Result Http.Error ()) - | ConfirmDeleteTimeEntry Int - | ConfirmDeleteUser Int - | DeleteConfirmed Bool - | StartEditingTimeEntry Int TimeEntry - | CancelEditingTimeEntry - | UpdateEditingTimeEntryDate String - | UpdateEditingTimeEntryStartTime String - | UpdateEditingTimeEntryEndTime String - | UpdateEditingTimeEntryType String - | SaveEditingTimeEntry - | SelectUserForManagement Int - | UpdateUserWorkHours String - | UpdateUserPassword String - | SaveUserPassword - | UserPasswordSaved (Result Http.Error ()) - | ToggleMobileMenu - | CloseMobileMenu - | SelectUserForManualEntry Int - | UpdateManualEntryDate String - | UpdateManualEntryHours String - | UpdateManualEntryType String - | SaveAdminTimeEntry - | AdminTimeEntrySaved (Result Http.Error ()) - | FetchMyInfo - | MyInfoReceived (Result Http.Error User) - | FetchSchoolYears - | SchoolYearsReceived (Result Http.Error (List SchoolYear)) - | FetchActiveSchoolYear - | ActiveSchoolYearReceived (Result Http.Error SchoolYear) - | UpdateNewSchoolYearName String - | UpdateNewSchoolYearStart String - | UpdateNewSchoolYearEnd String - | CreateSchoolYear - | SchoolYearCreated (Result Http.Error ()) - | ActivateSchoolYear Int - | SchoolYearActivated (Result Http.Error ()) - | DeleteSchoolYear Int - | SchoolYearDeleted (Result Http.Error ()) - | DownloadYearlySummaryPDF - | YearlySummaryPDFReceived (Result Http.Error Bytes) - | ShowToast String ToastType - | DismissToast Int - | AutoDismissToast Int diff --git a/frontend/src/Types/Page.elm b/frontend/src/Types/Page.elm deleted file mode 100644 index 5b41054..0000000 --- a/frontend/src/Types/Page.elm +++ /dev/null @@ -1,17 +0,0 @@ -module Types.Page exposing - ( AdminTab(..) - , Page(..) - ) - - -type Page - = LoginPage - | UserDashboard - | AdminDashboard - - -type AdminTab - = ScheduleTab - | UsersTab - | TimeEntriesTab - | SchoolYearsTab diff --git a/frontend/src/Update/AuthUpdate.elm b/frontend/src/Update/AuthUpdate.elm deleted file mode 100644 index 20a1fbc..0000000 --- a/frontend/src/Update/AuthUpdate.elm +++ /dev/null @@ -1,115 +0,0 @@ -module Update.AuthUpdate exposing - ( handleLogin - , handleLoginResponse - , handleLogout - ) - -import Api.Auth -import Api.Schedule -import Api.SchoolYear -import Api.TimeEntry -import Api.User -import Http -import Json.Encode as Encode -import Task -import Types.Model exposing (Model, ToastType(..)) -import Types.Msg exposing (Msg(..)) -import Types.Page exposing (Page(..)) -import Utils.DateUtils exposing (getISOWeekFromPosix) -import Utils.Ports exposing (removeToken, saveToken) - - -handleLogin : Model -> ( Model, Cmd Msg ) -handleLogin model = - if model.isProcessing then - ( model, Cmd.none ) - - else - ( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password ) - - -handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg ) -handleLoginResponse result model = - case result of - Ok loginResult -> - let - newPage = - if loginResult.isAdmin then - AdminDashboard - - else - UserDashboard - - ( year, week ) = - getISOWeekFromPosix model.currentTime - - tokenData = - Encode.object - [ ( "token", Encode.string loginResult.token ) - , ( "isAdmin", Encode.bool loginResult.isAdmin ) - ] - in - ( { model - | token = Just loginResult.token - , username = loginResult.username - , isAdmin = loginResult.isAdmin - , page = newPage - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ saveToken tokenData - , Api.Schedule.fetchSchedules (Just loginResult.token) - , Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ()) - , if not loginResult.isAdmin then - Cmd.batch - [ Api.TimeEntry.fetchMyTimeEntries loginResult.token - , Api.TimeEntry.fetchWeekDates loginResult.token year week - , Api.TimeEntry.checkWeekHasEntries loginResult.token year week - , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token - , Api.User.fetchMyInfo loginResult.token - ] - - else - Cmd.batch - [ Api.TimeEntry.fetchMyTimeEntries loginResult.token - , Api.TimeEntry.fetchWeekDates loginResult.token year week - , Api.TimeEntry.checkWeekHasEntries loginResult.token year week - , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token - ] - ] - ) - - Err err -> - let - errorMsg = - case err of - Http.BadStatus 401 -> - "Benutzername oder Passwort ungültig" - - Http.Timeout -> - "Zeitüberschreitung - bitte erneut versuchen" - - Http.NetworkError -> - "Netzwerkfehler - bitte Verbindung prüfen" - - _ -> - "Anmeldung fehlgeschlagen" - in - ( { model | isProcessing = False } - , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) - ) - - -handleLogout : Model -> ( Model, Cmd Msg ) -handleLogout model = - ( { model - | page = LoginPage - , token = Nothing - , isAdmin = False - , username = "" - , password = "" - , isProcessing = False - } - , removeToken () - ) diff --git a/frontend/src/Update/ScheduleUpdate.elm b/frontend/src/Update/ScheduleUpdate.elm deleted file mode 100644 index 2312e13..0000000 --- a/frontend/src/Update/ScheduleUpdate.elm +++ /dev/null @@ -1,244 +0,0 @@ -module Update.ScheduleUpdate exposing - ( handleCreateSchedule - , handleDeleteSchedule - , handleDeleteWeekEntries - , handleDisableEditMode - , handleEnableEditMode - , handleSaveTimeEntries - , handleScheduleCreated - , handleScheduleDeleted - , handleSchedulesReceived - , handleTimeEntriesSaved - , handleToggleScheduleSelection - , handleWeekEntriesDeleted - ) - -import Api.Schedule -import Api.TimeEntry -import Http -import Task -import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..)) -import Types.Msg exposing (Msg(..)) -import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate) - - -handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg ) -handleToggleScheduleSelection scheduleId dayOfWeek model = - let - entry = - { scheduleId = scheduleId, dayOfWeek = dayOfWeek } - - newSelected = - if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then - List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries - - else - entry :: model.selectedEntries - in - ( { model | selectedEntries = newSelected }, Cmd.none ) - - -handleSaveTimeEntries : Model -> ( Model, Cmd Msg ) -handleSaveTimeEntries model = - case model.token of - Just token -> - ( { model | error = Nothing } - , Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates - ) - - Nothing -> - ( model, Cmd.none ) - - -handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleTimeEntriesSaved result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | error = Nothing - , weekEditMode = False - , hasEntriesForCurrentWeek = True - } - , Cmd.batch - [ Api.TimeEntry.fetchMyTimeEntries token - , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleEnableEditMode : Model -> ( Model, Cmd Msg ) -handleEnableEditMode model = - let - currentWeekEntries = - List.filter - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - - preSelectedEntries = - List.map - (\entry -> - let - parts = - String.split "-" entry.date - - year = - parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - - month = - parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - day = - parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - dayOfWeek = - getDayOfWeek year month day - in - { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } - ) - currentWeekEntries - in - ( { model - | weekEditMode = True - , selectedEntries = preSelectedEntries - } - , Cmd.none - ) - - -handleDisableEditMode : Model -> ( Model, Cmd Msg ) -handleDisableEditMode model = - ( { model | weekEditMode = False }, Cmd.none ) - - -handleDeleteWeekEntries : Model -> ( Model, Cmd Msg ) -handleDeleteWeekEntries model = - case model.token of - Just token -> - ( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - -handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleWeekEntriesDeleted result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | weekEditMode = True - , selectedEntries = [] - , hasEntriesForCurrentWeek = False - } - , Cmd.batch - [ Api.TimeEntry.fetchMyTimeEntries token - , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleCreateSchedule : Model -> ( Model, Cmd Msg ) -handleCreateSchedule model = - if - String.isEmpty model.newSchedule.dayOfWeek - || String.isEmpty model.newSchedule.startTime - || String.isEmpty model.newSchedule.endTime - then - ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) - - else - case model.token of - Just token -> - ( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule ) - - Nothing -> - ( model, Cmd.none ) - - -handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleScheduleCreated result model = - case result of - Ok _ -> - case model.token of - Just token -> - let - emptySchedule = - NewSchedule "" "" "" "lesson" "" - in - ( { model - | newSchedule = emptySchedule - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ Api.Schedule.fetchSchedules model.token - , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( { model | isProcessing = False }, Cmd.none ) - - -handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg ) -handleDeleteSchedule scheduleId model = - case model.token of - Just token -> - ( model, Api.Schedule.deleteSchedule token scheduleId ) - - Nothing -> - ( model, Cmd.none ) - - -handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleScheduleDeleted result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ Api.Schedule.fetchSchedules (Just token) - , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg ) -handleSchedulesReceived result model = - case result of - Ok schedules -> - ( { model | schedules = schedules }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) diff --git a/frontend/src/Update/SchoolYearUpdate.elm b/frontend/src/Update/SchoolYearUpdate.elm deleted file mode 100644 index 0de741d..0000000 --- a/frontend/src/Update/SchoolYearUpdate.elm +++ /dev/null @@ -1,139 +0,0 @@ -module Update.SchoolYearUpdate exposing - ( handleActivateSchoolYear - , handleActiveSchoolYearReceived - , handleCreateSchoolYear - , handleDeleteSchoolYear - , handleSchoolYearActivated - , handleSchoolYearCreated - , handleSchoolYearDeleted - , handleSchoolYearsReceived - ) - -import Api.SchoolYear -import Http -import Task -import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..)) -import Types.Msg exposing (Msg(..)) - - -handleCreateSchoolYear : Model -> ( Model, Cmd Msg ) -handleCreateSchoolYear model = - if - String.isEmpty model.newSchoolYear.name - || String.isEmpty model.newSchoolYear.startDate - || String.isEmpty model.newSchoolYear.endDate - then - ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) - - else - case model.token of - Just token -> - ( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear ) - - Nothing -> - ( model, Cmd.none ) - - -handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleSchoolYearCreated result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | newSchoolYear = NewSchoolYear "" "" "" - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ Api.SchoolYear.fetchSchoolYears token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( { model | isProcessing = False }, Cmd.none ) - - -handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg ) -handleActivateSchoolYear id model = - case model.token of - Just token -> - ( model, Api.SchoolYear.activateSchoolYear token id ) - - Nothing -> - ( model, Cmd.none ) - - -handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleSchoolYearActivated result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ Api.SchoolYear.fetchSchoolYears token - , Api.SchoolYear.fetchActiveSchoolYear token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg ) -handleDeleteSchoolYear id model = - case model.token of - Just token -> - ( model, Api.SchoolYear.deleteSchoolYear token id ) - - Nothing -> - ( model, Cmd.none ) - - -handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleSchoolYearDeleted result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ Api.SchoolYear.fetchSchoolYears token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg ) -handleSchoolYearsReceived result model = - case result of - Ok years -> - ( { model | schoolYears = years }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg ) -handleActiveSchoolYearReceived result model = - case result of - Ok year -> - ( { model | activeSchoolYear = Just year }, Cmd.none ) - - Err _ -> - ( { model | activeSchoolYear = Nothing }, Cmd.none ) diff --git a/frontend/src/Update/TimeEntryUpdate.elm b/frontend/src/Update/TimeEntryUpdate.elm deleted file mode 100644 index a794944..0000000 --- a/frontend/src/Update/TimeEntryUpdate.elm +++ /dev/null @@ -1,189 +0,0 @@ -module Update.TimeEntryUpdate exposing - ( handleAdminTimeEntrySaved - , handleAllTimeEntriesReceived - , handleConfirmDeleteTimeEntry - , handleEditTimeEntry - , handleMyTimeEntriesReceived - , handleSaveAdminTimeEntry - , handleSaveEditTimeEntry - , handleTimeEntryDeleted - , handleTimeEntrySaved - , handleYearlyHoursSummaryReceived - ) - -import Api.TimeEntry -import Http -import Task -import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary) -import Types.Msg exposing (Msg(..)) -import Utils.DateUtils exposing (getYearWeekFromDate) -import Utils.Ports exposing (confirmDelete) - - -handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) -handleMyTimeEntriesReceived result model = - case result of - Ok entries -> - let - hasEntries = - List.any - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - entries - in - ( { model - | timeEntries = entries - , hasEntriesForCurrentWeek = hasEntries - , weekEditMode = False - } - , Cmd.none - ) - - Err err -> - ( model, Cmd.none ) - - -handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) -handleAllTimeEntriesReceived result model = - case result of - Ok entries -> - ( { model | timeEntries = entries }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg ) -handleEditTimeEntry entryId model = - case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of - Just entry -> - ( { model - | editingTimeEntryId = Just entryId - , editingTimeEntry = - { entryId = entryId - , date = entry.date - , startTime = entry.startTime - , endTime = entry.endTime - , entryType = entry.entryType - } - } - , Cmd.none - ) - - Nothing -> - ( model, Cmd.none ) - - -handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg ) -handleSaveEditTimeEntry model = - case model.token of - Just token -> - ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) - - Nothing -> - ( model, Cmd.none ) - - -handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleTimeEntrySaved result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | editingTimeEntryId = Nothing - , pendingDeleteId = Nothing - , error = Nothing - } - , Cmd.batch - [ Api.TimeEntry.fetchAllTimeEntries token - , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleTimeEntryDeleted result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - , pendingDeleteId = Nothing - , error = Nothing - } - , Cmd.batch - [ Api.TimeEntry.fetchAllTimeEntries token - , Api.TimeEntry.fetchYearlyHoursSummary token - , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( { model | pendingDeleteId = Nothing }, Cmd.none ) - - -handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg ) -handleConfirmDeleteTimeEntry entryId model = - ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) - - -handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg ) -handleSaveAdminTimeEntry model = - case model.token of - Just token -> - ( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm ) - - Nothing -> - ( model, Cmd.none ) - - -handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleAdminTimeEntrySaved result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ Api.TimeEntry.fetchAllTimeEntries token - , Api.TimeEntry.fetchYearlyHoursSummary token - , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( { model | isProcessing = False }, Cmd.none ) - - -handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg ) -handleYearlyHoursSummaryReceived result model = - case result of - Ok summary -> - ( { model | yearlyHoursSummary = summary }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) diff --git a/frontend/src/Update/Update.elm b/frontend/src/Update/Update.elm deleted file mode 100644 index f384b8c..0000000 --- a/frontend/src/Update/Update.elm +++ /dev/null @@ -1,811 +0,0 @@ -module Update.Update exposing (update) - -import Api.Schedule -import Api.SchoolYear -import Api.TimeEntry -import Api.User -import File.Download -import Process -import Task -import Time -import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..)) -import Types.Msg exposing (Msg(..)) -import Types.Page exposing (AdminTab(..), Page(..)) -import Update.AuthUpdate as Auth -import Update.ScheduleUpdate as Schedule -import Update.SchoolYearUpdate as SchoolYear -import Update.TimeEntryUpdate as TimeEntry -import Update.UserUpdate as User -import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek) -import Utils.Ports - - -update : Msg -> Model -> ( Model, Cmd Msg ) -update msg model = - case msg of - -- Mobile Menu - ToggleMobileMenu -> - ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) - - CloseMobileMenu -> - ( { model | mobileMenuOpen = False }, Cmd.none ) - - -- Auth - UpdateUsername username -> - ( { model | username = username }, Cmd.none ) - - UpdatePassword password -> - ( { model | password = password }, Cmd.none ) - - Login -> - Auth.handleLogin model - - LoginResponse result -> - Auth.handleLoginResponse result model - - Logout -> - Auth.handleLogout model - - -- Time - SetTime time -> - let - ( year, week ) = - getISOWeekFromPosix time - - cmds = - case model.token of - Just token -> - if model.page == UserDashboard || model.page == LoginPage then - Cmd.batch - [ Api.TimeEntry.checkWeekHasEntries token year week - , Api.TimeEntry.fetchWeekDates token year week - , Api.TimeEntry.fetchMyTimeEntries token - ] - - else - Cmd.none - - Nothing -> - Cmd.none - in - ( { model - | currentTime = time - , currentWeek = week - , currentYear = year - } - , cmds - ) - - -- Schedules - FetchSchedules -> - ( model, Api.Schedule.fetchSchedules model.token ) - - SchedulesReceived result -> - Schedule.handleSchedulesReceived result model - - ToggleScheduleSelection scheduleId dayOfWeek -> - Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model - - SaveTimeEntries -> - Schedule.handleSaveTimeEntries model - - TimeEntriesSaved result -> - Schedule.handleTimeEntriesSaved result model - - EnableEditMode -> - Schedule.handleEnableEditMode model - - DisableEditMode -> - Schedule.handleDisableEditMode model - - DeleteWeekEntries -> - Schedule.handleDeleteWeekEntries model - - WeekEntriesDeleted result -> - Schedule.handleWeekEntriesDeleted result model - - CreateSchedule -> - Schedule.handleCreateSchedule model - - ScheduleCreated result -> - Schedule.handleScheduleCreated result model - - DeleteSchedule scheduleId -> - Schedule.handleDeleteSchedule scheduleId model - - ScheduleDeleted result -> - Schedule.handleScheduleDeleted result model - - -- Week Navigation - PreviousWeek -> - let - ( newYear, newWeek ) = - previousWeek model.currentYear model.currentWeek - in - ( { model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - } - , case model.token of - Just token -> - Cmd.batch - [ Api.TimeEntry.fetchWeekDates token newYear newWeek - , Api.TimeEntry.checkWeekHasEntries token newYear newWeek - ] - - Nothing -> - Cmd.none - ) - - NextWeek -> - let - ( newYear, newWeek ) = - nextWeek model.currentYear model.currentWeek - in - ( { model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - } - , case model.token of - Just token -> - Cmd.batch - [ Api.TimeEntry.fetchWeekDates token newYear newWeek - , Api.TimeEntry.checkWeekHasEntries token newYear newWeek - ] - - Nothing -> - Cmd.none - ) - - FetchWeekDates -> - case model.token of - Just token -> - ( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekDatesReceived result -> - case result of - Ok weekDates -> - ( { model | weekDates = Just weekDates }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - CheckWeekHasEntries -> - case model.token of - Just token -> - ( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekHasEntriesReceived result -> - case result of - Ok hasEntries -> - ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -- Admin Tabs - SwitchTab tab -> - let - cmd = - case tab of - UsersTab -> - case model.token of - Just token -> - Api.User.fetchUsers token - - Nothing -> - Cmd.none - - TimeEntriesTab -> - case model.token of - Just token -> - Cmd.batch - [ Api.TimeEntry.fetchAllTimeEntries token - , Api.TimeEntry.fetchYearlyHoursSummary token - ] - - Nothing -> - Cmd.none - - SchoolYearsTab -> - case model.token of - Just token -> - Cmd.batch - [ Api.SchoolYear.fetchSchoolYears token - , Api.SchoolYear.fetchActiveSchoolYear token - ] - - Nothing -> - Cmd.none - - _ -> - Cmd.none - in - ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) - - -- Schedule Form - UpdateNewScheduleDay day -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | dayOfWeek = day } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleStart time -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | startTime = time } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleEnd time -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | endTime = time } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleType scheduleType -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | scheduleType = scheduleType } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleTitle title -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | title = title } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - -- Users - UpdateNewUsername username -> - let - oldUser = - model.newUser - - newUser = - { oldUser | username = username } - in - ( { model | newUser = newUser }, Cmd.none ) - - UpdateNewPassword password -> - let - oldUser = - model.newUser - - newUser = - { oldUser | password = password } - in - ( { model | newUser = newUser }, Cmd.none ) - - UpdateNewUserAdmin isAdmin -> - let - oldUser = - model.newUser - - newUser = - { oldUser | isAdmin = isAdmin } - in - ( { model | newUser = newUser }, Cmd.none ) - - CreateUser -> - User.handleCreateUser model - - UserCreated result -> - User.handleUserCreated result model - - DeleteUser userId -> - User.handleDeleteUser userId model - - UserDeleted result -> - User.handleUserDeleted result model - - FetchUsers -> - case model.token of - Just token -> - ( model, Api.User.fetchUsers token ) - - Nothing -> - ( model, Cmd.none ) - - UsersReceived result -> - User.handleUsersReceived result model - - EditUserWorkHours userId -> - User.handleEditUserWorkHours userId model - - CancelEditUserWorkHours -> - ( { model - | editingUserId = Nothing - , editingUserWorkHours = "" - } - , Cmd.none - ) - - UpdateEditUserWorkHours hours -> - ( { model | editingUserWorkHours = hours }, Cmd.none ) - - SaveUserWorkHours -> - User.handleSaveUserWorkHours model - - UserWorkHoursSaved result -> - User.handleUserWorkHoursSaved result model - - ResetUserPassword userId -> - User.handleResetUserPassword userId model - - CancelResetPassword -> - ( { model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - } - , Cmd.none - ) - - UpdateResetPasswordNew password -> - ( { model | resetPasswordNew = password }, Cmd.none ) - - SaveResetPassword -> - User.handleSaveResetPassword model - - ResetPasswordSaved result -> - User.handleResetPasswordSaved result model - - UpdateUserWorkHours input -> - ( { model | userWorkHoursInput = input }, Cmd.none ) - - UpdateUserPassword input -> - ( { model | userPasswordInput = input }, Cmd.none ) - - SaveUserPassword -> - case ( model.token, model.selectedUserId ) of - ( Just token, Just userId ) -> - if String.length model.userPasswordInput > 0 then - ( model, Api.User.resetUserPassword token userId model.userPasswordInput ) - - else - ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) - - _ -> - ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) - - UserPasswordSaved result -> - case result of - Ok _ -> - ( { model - | userPasswordInput = "" - , selectedUserId = Nothing - , error = Nothing - } - , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) - ) - - Err err -> - ( model, Cmd.none ) - - SelectUserForManagement userId -> - ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) - - -- Time Entries - FetchMyTimeEntries -> - case model.token of - Just token -> - ( model, Api.TimeEntry.fetchMyTimeEntries token ) - - Nothing -> - ( model, Cmd.none ) - - MyTimeEntriesReceived result -> - TimeEntry.handleMyTimeEntriesReceived result model - - FetchAllTimeEntries -> - case model.token of - Just token -> - ( model, Api.TimeEntry.fetchAllTimeEntries token ) - - Nothing -> - ( model, Cmd.none ) - - AllTimeEntriesReceived result -> - TimeEntry.handleAllTimeEntriesReceived result model - - EditTimeEntry entryId -> - TimeEntry.handleEditTimeEntry entryId model - - CancelEditTimeEntry -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" - } - , Cmd.none - ) - - UpdateEditTimeEntryDate date -> - let - old = - model.editingTimeEntry - - new = - { old | date = date } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryStartTime time -> - let - old = - model.editingTimeEntry - - new = - { old | startTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryEndTime time -> - let - old = - model.editingTimeEntry - - new = - { old | endTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryType entryType -> - let - old = - model.editingTimeEntry - - new = - { old | entryType = entryType } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - SaveEditTimeEntry -> - TimeEntry.handleSaveEditTimeEntry model - - TimeEntrySaved result -> - TimeEntry.handleTimeEntrySaved result model - - TimeEntryDeleted result -> - TimeEntry.handleTimeEntryDeleted result model - - ConfirmDeleteTimeEntry entryId -> - TimeEntry.handleConfirmDeleteTimeEntry entryId model - - StartEditingTimeEntry entryId entry -> - ( { model - | editingTimeEntryId = Just entryId - , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType - } - , Cmd.none - ) - - CancelEditingTimeEntry -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - } - , Cmd.none - ) - - UpdateEditingTimeEntryDate date -> - let - old = - model.editingTimeEntry - - new = - { old | date = date } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryStartTime time -> - let - old = - model.editingTimeEntry - - new = - { old | startTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryEndTime time -> - let - old = - model.editingTimeEntry - - new = - { old | endTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryType entryType -> - let - old = - model.editingTimeEntry - - new = - { old | entryType = entryType } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - SaveEditingTimeEntry -> - case ( model.token, model.editingTimeEntryId ) of - ( Just token, Just entryId ) -> - ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) - - _ -> - ( model, Cmd.none ) - - -- Weekly Hours - FetchWeeklyHours -> - case model.token of - Just token -> - ( model, Cmd.none ) - - Nothing -> - ( model, Cmd.none ) - - WeeklyHoursReceived result -> - case result of - Ok hours -> - ( { model | weeklyHours = hours }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - MyWeeklySummaryReceived result -> - case result of - Ok summary -> - ( { model | userWeeklySummary = Just summary }, Cmd.none ) - - Err _ -> - ( { model | userWeeklySummary = Nothing }, Cmd.none ) - - -- Yearly Hours - FetchYearlyHoursSummary -> - case model.token of - Just token -> - ( model, Api.TimeEntry.fetchYearlyHoursSummary token ) - - Nothing -> - ( model, Cmd.none ) - - YearlyHoursSummaryReceived result -> - TimeEntry.handleYearlyHoursSummaryReceived result model - - -- Admin Manual Entry - SelectUserForManualEntry userId -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none ) - - UpdateManualEntryDate date -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) - - UpdateManualEntryHours hours -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) - - UpdateManualEntryType entryType -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none ) - - SaveAdminTimeEntry -> - TimeEntry.handleSaveAdminTimeEntry model - - AdminTimeEntrySaved result -> - TimeEntry.handleAdminTimeEntrySaved result model - - -- My Info - FetchMyInfo -> - case model.token of - Just token -> - ( model, Api.User.fetchMyInfo token ) - - Nothing -> - ( model, Cmd.none ) - - MyInfoReceived result -> - case result of - Ok user -> - ( { model | users = [ user ] }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -- School Years - FetchSchoolYears -> - case model.token of - Just token -> - ( model, Api.SchoolYear.fetchSchoolYears token ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearsReceived result -> - SchoolYear.handleSchoolYearsReceived result model - - FetchActiveSchoolYear -> - case model.token of - Just token -> - ( model, Api.SchoolYear.fetchActiveSchoolYear token ) - - Nothing -> - ( model, Cmd.none ) - - ActiveSchoolYearReceived result -> - SchoolYear.handleActiveSchoolYearReceived result model - - UpdateNewSchoolYearName name -> - let - old = - model.newSchoolYear - - new = - { old | name = name } - in - ( { model | newSchoolYear = new }, Cmd.none ) - - UpdateNewSchoolYearStart date -> - let - old = - model.newSchoolYear - - new = - { old | startDate = date } - in - ( { model | newSchoolYear = new }, Cmd.none ) - - UpdateNewSchoolYearEnd date -> - let - old = - model.newSchoolYear - - new = - { old | endDate = date } - in - ( { model | newSchoolYear = new }, Cmd.none ) - - CreateSchoolYear -> - SchoolYear.handleCreateSchoolYear model - - SchoolYearCreated result -> - SchoolYear.handleSchoolYearCreated result model - - ActivateSchoolYear id -> - SchoolYear.handleActivateSchoolYear id model - - SchoolYearActivated result -> - SchoolYear.handleSchoolYearActivated result model - - DeleteSchoolYear id -> - SchoolYear.handleDeleteSchoolYear id model - - SchoolYearDeleted result -> - SchoolYear.handleSchoolYearDeleted result model - - -- PDF Download - DownloadYearlySummaryPDF -> - case model.token of - Just token -> - ( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token ) - - Nothing -> - ( model, Cmd.none ) - - YearlySummaryPDFReceived result -> - case result of - Ok pdfBytes -> - let - filename = - "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" - in - ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) - - Err err -> - ( { model | isProcessing = False }, Cmd.none ) - - -- Delete Confirmation - ConfirmDeleteUser userId -> - ( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) - - DeleteConfirmed confirmed -> - if confirmed then - case ( model.token, model.pendingDeleteId ) of - ( Just token, Just id ) -> - let - isTimeEntry = - List.any (\e -> e.id == id) model.timeEntries - in - if isTimeEntry then - ( model, Api.TimeEntry.deleteTimeEntry token id ) - - else - ( model, Api.User.deleteUser token id ) - - _ -> - ( model, Cmd.none ) - - else - ( { model | pendingDeleteId = Nothing }, Cmd.none ) - - -- Toasts - ShowToast message toastType -> - let - newToast = - { id = model.nextToastId - , message = message - , toastType = toastType - , dismissible = True - } - - dismissDelay = - case toastType of - ErrorToast -> - 8000 - - SuccessToast -> - 5000 - - InfoToast -> - 5000 - - WarningToast -> - 6000 - in - ( { model - | toasts = model.toasts ++ [ newToast ] - , nextToastId = model.nextToastId + 1 - } - , Task.perform (\_ -> AutoDismissToast newToast.id) - (Process.sleep dismissDelay) - ) - - DismissToast toastId -> - ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } - , Cmd.none - ) - - AutoDismissToast toastId -> - ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } - , Cmd.none - ) diff --git a/frontend/src/Update/UserUpdate.elm b/frontend/src/Update/UserUpdate.elm deleted file mode 100644 index 9fd4b85..0000000 --- a/frontend/src/Update/UserUpdate.elm +++ /dev/null @@ -1,196 +0,0 @@ -module Update.UserUpdate exposing - ( handleCreateUser - , handleDeleteUser - , handleEditUserWorkHours - , handleResetPasswordSaved - , handleResetUserPassword - , handleSaveResetPassword - , handleSaveUserWorkHours - , handleUserCreated - , handleUserDeleted - , handleUserWorkHoursSaved - , handleUsersReceived - ) - -import Api.User -import Http -import Task -import Types.Model exposing (Model, NewUser, ToastType(..), User) -import Types.Msg exposing (Msg(..)) - - -handleCreateUser : Model -> ( Model, Cmd Msg ) -handleCreateUser model = - case model.token of - Just token -> - ( model, Api.User.createUser token model.newUser ) - - Nothing -> - ( model, Cmd.none ) - - -handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleUserCreated result model = - case result of - Ok _ -> - let - emptyUser = - NewUser "" "" False - in - case model.token of - Just token -> - ( { model | newUser = emptyUser } - , Cmd.batch - [ Api.User.fetchUsers token - , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleDeleteUser : Int -> Model -> ( Model, Cmd Msg ) -handleDeleteUser userId model = - case model.token of - Just token -> - ( model, Api.User.deleteUser token userId ) - - Nothing -> - ( model, Cmd.none ) - - -handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleUserDeleted result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | pendingDeleteId = Nothing - , error = Nothing - , editingUserId = Nothing - , resetPasswordUserId = Nothing - } - , Cmd.batch - [ Api.User.fetchUsers token - , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( { model | pendingDeleteId = Nothing }, Cmd.none ) - - -handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg ) -handleUsersReceived result model = - case result of - Ok users -> - ( { model | users = users }, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg ) -handleEditUserWorkHours userId model = - case List.filter (\u -> u.id == userId) model.users |> List.head of - Just user -> - ( { model - | editingUserId = Just userId - , editingUserWorkHours = String.fromFloat user.yearlyWorkHours - } - , Cmd.none - ) - - Nothing -> - ( model, Cmd.none ) - - -handleSaveUserWorkHours : Model -> ( Model, Cmd Msg ) -handleSaveUserWorkHours model = - case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of - ( Just token, Just userId, Just hours ) -> - ( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) ) - - _ -> - ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) - - -handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleUserWorkHoursSaved result model = - case result of - Ok _ -> - case model.token of - Just token -> - ( { model - | editingUserWorkHours = "" - , editingUserId = Nothing - , error = Nothing - } - , Cmd.batch - [ Api.User.fetchUsers token - , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - Err err -> - ( model, Cmd.none ) - - -handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg ) -handleResetUserPassword userId model = - ( { model - | resetPasswordUserId = Just userId - , resetPasswordNew = "" - } - , Cmd.none - ) - - -handleSaveResetPassword : Model -> ( Model, Cmd Msg ) -handleSaveResetPassword model = - case model.resetPasswordUserId of - Just userId -> - case model.token of - Just token -> - ( model, Api.User.resetUserPassword token userId model.resetPasswordNew ) - - Nothing -> - ( model, Cmd.none ) - - Nothing -> - ( model, Cmd.none ) - - -handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) -handleResetPasswordSaved result model = - case result of - Ok _ -> - ( { model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - , error = Nothing - } - , Cmd.batch - [ case model.token of - Just token -> - Api.User.fetchUsers token - - Nothing -> - Cmd.none - , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) - ] - ) - - Err err -> - ( model, Cmd.none ) diff --git a/frontend/src/Utils/DateUtils.elm b/frontend/src/Utils/DateUtils.elm deleted file mode 100644 index 1ea98dd..0000000 --- a/frontend/src/Utils/DateUtils.elm +++ /dev/null @@ -1,338 +0,0 @@ -module Utils.DateUtils exposing - ( addDaysToDate - , getDateForWeekDay - , getDayOfWeek - , getDayOfYear - , getISOWeek - , getISOWeekFromPosix - , getWeekDateRange - , getYearWeekFromDate - , isLeapYear - , monthToInt - , nextWeek - , previousWeek - ) - -import Time - - -getISOWeekFromPosix : Time.Posix -> ( Int, Int ) -getISOWeekFromPosix time = - let - year = - Time.toYear Time.utc time - - month = - Time.toMonth Time.utc time |> monthToInt - - day = - Time.toDay Time.utc time - in - ( year, getISOWeek year month day ) - - -monthToInt : Time.Month -> Int -monthToInt month = - case month of - Time.Jan -> - 1 - - Time.Feb -> - 2 - - Time.Mar -> - 3 - - Time.Apr -> - 4 - - Time.May -> - 5 - - Time.Jun -> - 6 - - Time.Jul -> - 7 - - Time.Aug -> - 8 - - Time.Sep -> - 9 - - Time.Oct -> - 10 - - Time.Nov -> - 11 - - Time.Dec -> - 12 - - -getISOWeek : Int -> Int -> Int -> Int -getISOWeek year month day = - let - dayOfYear = - getDayOfYear year month day - - jan4DayOfWeek = - getDayOfWeek year 1 4 - - mondayOfWeek1DayOfYear = - 4 - jan4DayOfWeek - - weekNum = - ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 - in - if weekNum < 1 then - 52 - - else if weekNum > 52 then - let - dec31DayOfWeek = - getDayOfWeek year 12 31 - - jan1DayOfWeek = - getDayOfWeek year 1 1 - in - if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then - weekNum - - else - 1 - - else - weekNum - - -getDayOfYear : Int -> Int -> Int -> Int -getDayOfYear year month day = - let - daysInMonth = - [ 31 - , if isLeapYear year then - 29 - - else - 28 - , 31 - , 30 - , 31 - , 30 - , 31 - , 31 - , 30 - , 31 - , 30 - , 31 - ] - - daysBefore = - List.take (month - 1) daysInMonth |> List.sum - in - daysBefore + day - - -isLeapYear : Int -> Bool -isLeapYear year = - (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) - - -getDayOfWeek : Int -> Int -> Int -> Int -getDayOfWeek year month day = - let - adjustedMonth = - if month < 3 then - month + 12 - - else - month - - adjustedYear = - if month < 3 then - year - 1 - - else - year - - q = - day - - m = - adjustedMonth - - k = - modBy 100 adjustedYear - - j = - adjustedYear // 100 - - h = - (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 - in - (h + 5) |> modBy 7 - - -getDateForWeekDay : Int -> Int -> Int -> String -getDateForWeekDay year week dayOfWeek = - let - jan4DayOfWeek = - getDayOfWeek year 1 4 - - mondayOfWeek1Date = - 4 - jan4DayOfWeek - - targetDayOfYear = - mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek - - ( finalYear, finalMonth, finalDay ) = - if targetDayOfYear < 1 then - addDaysToDate (year - 1) 12 31 targetDayOfYear - - else - addDaysToDate year 1 targetDayOfYear 0 - in - String.fromInt finalYear - ++ "-" - ++ String.padLeft 2 '0' (String.fromInt finalMonth) - ++ "-" - ++ String.padLeft 2 '0' (String.fromInt finalDay) - - -addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) -addDaysToDate startYear startMonth startDay daysToAdd = - let - daysInMonth m y = - case m of - 1 -> - 31 - - 2 -> - if isLeapYear y then - 29 - - else - 28 - - 3 -> - 31 - - 4 -> - 30 - - 5 -> - 31 - - 6 -> - 30 - - 7 -> - 31 - - 8 -> - 31 - - 9 -> - 30 - - 10 -> - 31 - - 11 -> - 30 - - 12 -> - 31 - - _ -> - 0 - - helper y m d remaining = - if remaining == 0 then - ( y, m, d ) - - else if remaining > 0 then - let - daysInCurrentMonth = - daysInMonth m y - - daysLeftInMonth = - daysInCurrentMonth - d - in - if remaining <= daysLeftInMonth then - ( y, m, d + remaining ) - - else if m == 12 then - helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) - - else - helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) - - else if d + remaining >= 1 then - ( y, m, d + remaining ) - - else if m == 1 then - let - prevMonthDays = - daysInMonth 12 (y - 1) - in - helper (y - 1) 12 prevMonthDays (remaining + d) - - else - let - prevMonthDays = - daysInMonth (m - 1) y - in - helper y (m - 1) prevMonthDays (remaining + d) - in - helper startYear startMonth startDay daysToAdd - - -previousWeek : Int -> Int -> ( Int, Int ) -previousWeek year week = - if week == 1 then - ( year - 1, 52 ) - - else - ( year, week - 1 ) - - -nextWeek : Int -> Int -> ( Int, Int ) -nextWeek year week = - if week >= 52 then - ( year + 1, 1 ) - - else - ( year, week + 1 ) - - -getWeekDateRange : Int -> Int -> String -getWeekDateRange year week = - let - mondayDate = - getDateForWeekDay year week 0 - - fridayDate = - getDateForWeekDay year week 4 - in - mondayDate ++ " bis " ++ fridayDate - - -getYearWeekFromDate : String -> ( Int, Int ) -getYearWeekFromDate dateStr = - let - parts = - String.split "-" dateStr - - year = - parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - - month = - parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - day = - parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - in - ( year, getISOWeek year month day ) diff --git a/frontend/src/Utils/ErrorHandler.elm b/frontend/src/Utils/ErrorHandler.elm deleted file mode 100644 index a9746e2..0000000 --- a/frontend/src/Utils/ErrorHandler.elm +++ /dev/null @@ -1,42 +0,0 @@ -module Utils.ErrorHandler exposing (handleApiError) - -import Api.Decoders exposing (apiErrorDecoder) -import Http -import Json.Decode as Decode -import Task -import Types.Model exposing (ToastType(..)) -import Types.Msg exposing (Msg(..)) - - -handleApiError : Http.Error -> Cmd Msg -handleApiError error = - let - message = - case error of - Http.BadBody body -> - case Decode.decodeString apiErrorDecoder body of - Ok apiErr -> - apiErr.message - - Err _ -> - "Ein Fehler ist aufgetreten" - - Http.BadStatus 401 -> - "Keine Berechtigung - bitte erneut anmelden" - - Http.BadStatus 403 -> - "Zugriff verweigert" - - Http.BadStatus 404 -> - "Ressource nicht gefunden" - - Http.Timeout -> - "Zeitüberschreitung - bitte erneut versuchen" - - Http.NetworkError -> - "Netzwerkfehler - bitte Verbindung prüfen" - - _ -> - "Ein unerwarteter Fehler ist aufgetreten" - in - Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) diff --git a/frontend/src/Utils/Ports.elm b/frontend/src/Utils/Ports.elm deleted file mode 100644 index f5b8dc2..0000000 --- a/frontend/src/Utils/Ports.elm +++ /dev/null @@ -1,20 +0,0 @@ -port module Utils.Ports exposing - ( confirmDelete - , confirmDeleteResponse - , removeToken - , saveToken - ) - -import Json.Encode as Encode - - -port saveToken : Encode.Value -> Cmd msg - - -port removeToken : () -> Cmd msg - - -port confirmDelete : String -> Cmd msg - - -port confirmDeleteResponse : (Bool -> msg) -> Sub msg diff --git a/frontend/src/Utils/TimeUtils.elm b/frontend/src/Utils/TimeUtils.elm deleted file mode 100644 index 2d74958..0000000 --- a/frontend/src/Utils/TimeUtils.elm +++ /dev/null @@ -1,34 +0,0 @@ -module Utils.TimeUtils exposing (calculateHours) - - -calculateHours : String -> String -> Float -calculateHours startTime endTime = - let - parseTime timeStr = - case String.split ":" timeStr of - [ h, m ] -> - (String.toFloat h |> Maybe.withDefault 0) - + ((String.toFloat m |> Maybe.withDefault 0) / 60) - - _ -> - 0 - - start = - parseTime startTime - - end = - parseTime endTime - in - if end > start then - end - start - - else if endTime == "manual" then - case String.toFloat startTime of - Just time -> - time - - Nothing -> - 0 - - else - 0 diff --git a/frontend/src/View/AdminDashboard.elm b/frontend/src/View/AdminDashboard.elm deleted file mode 100644 index 9afcfb5..0000000 --- a/frontend/src/View/AdminDashboard.elm +++ /dev/null @@ -1,1165 +0,0 @@ -module View.AdminDashboard exposing (viewAdminDashboard) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Types.Model exposing (Model, Schedule, SchoolYear, TimeEntry, User, WeeklyHours, YearlyHoursSummary) -import Types.Msg exposing (Msg(..)) -import Types.Page exposing (AdminTab(..)) -import Utils.DateUtils exposing (getYearWeekFromDate) -import Utils.TimeUtils exposing (calculateHours) -import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) -import View.Components.Schedule exposing (viewScheduleItemWithDay) - - -viewAdminDashboard : Model -> Html Msg -viewAdminDashboard model = - div [] - [ nav [ class "navbar is-danger" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] - ] - , a - [ class - ("navbar-burger" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - , attribute "aria-label" "menu" - , attribute "aria-expanded" - (if model.mobileMenuOpen then - "true" - - else - "false" - ) - , onClick ToggleMobileMenu - ] - [ span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - ] - ] - , div - [ id "navbarAdmin" - , class - ("navbar-menu" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-2" ] [ text model.username ] - ] - , div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] - [ span [ class "icon" ] - [ i [ class "fas fa-sign-out-alt" ] [] ] - , span [] [ text "Abmelden" ] - ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ div [ class "tabs is-boxed" ] - [ ul [] - [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] - [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] - , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] - [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] - , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] - [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] - , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ] - [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ] - ] - ] - , case model.activeTab of - ScheduleTab -> - viewScheduleTab model - - UsersTab -> - viewUsersTab model - - TimeEntriesTab -> - viewTimeEntriesTab model - - SchoolYearsTab -> - viewSchoolYearsTab model - ] - ] - ] - - -viewScheduleTab : Model -> Html Msg -viewScheduleTab model = - div [] - [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] - , viewScheduleForm model - , viewScheduleList model - ] - - -viewUsersTab : Model -> Html Msg -viewUsersTab model = - div [] - [ h2 [ class "title" ] [ text "Benutzer verwalten" ] - , viewUserForm model - , viewUserList model - ] - - -viewTimeEntriesTab : Model -> Html Msg -viewTimeEntriesTab model = - div [] - [ h2 [ class "title" ] [ text "Jahresübersicht" ] - , viewYearlyHoursSummary model - , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] - , viewAdminManualEntryForm model - , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] - , case model.editingTimeEntryId of - Just _ -> - viewTimeEntriesEditForm model - - Nothing -> - viewTimeEntriesListWithEdit model - ] - - -viewSchoolYearsTab : Model -> Html Msg -viewSchoolYearsTab model = - div [] - [ h2 [ class "title" ] [ text "Schuljahre verwalten" ] - , case model.activeSchoolYear of - Just schoolYear -> - div [ class "notification is-info is-light mb-4" ] - [ p [ class "has-text-weight-bold" ] - [ text ("Aktives Schuljahr: " ++ schoolYear.name) ] - , p [ class "is-size-7" ] - [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ] - ] - - Nothing -> - div [ class "notification is-warning is-light mb-4" ] - [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ] - , viewSchoolYearForm model - , viewSchoolYearsList model - ] - - -viewSchoolYearForm : Model -> Html Msg -viewSchoolYearForm model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ] - , div [ class "columns" ] - [ div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "2024/2025" - , value model.newSchoolYear.name - , onInput UpdateNewSchoolYearName - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startdatum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.newSchoolYear.startDate - , onInput UpdateNewSchoolYearStart - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Enddatum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.newSchoolYear.endDate - , onInput UpdateNewSchoolYearEnd - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary" - , onClick CreateSchoolYear - , disabled - (String.isEmpty model.newSchoolYear.name - || String.isEmpty model.newSchoolYear.startDate - || String.isEmpty model.newSchoolYear.endDate - || model.isProcessing - ) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text " Schuljahr erstellen" - ] - ] - ] - ] - - -viewSchoolYearsList : Model -> Html Msg -viewSchoolYearsList model = - div [ class "box mt-4" ] - [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ] - , if List.isEmpty model.schoolYears then - p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Name" ] - , th [] [ text "Startdatum" ] - , th [] [ text "Enddatum" ] - , th [ class "has-text-centered" ] [ text "Status" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map viewSchoolYearRow model.schoolYears) - ] - ] - - -viewSchoolYearRow : SchoolYear -> Html Msg -viewSchoolYearRow schoolYear = - tr [] - [ td [] [ text schoolYear.name ] - , td [] [ text schoolYear.startDate ] - , td [] [ text schoolYear.endDate ] - , td [ class "has-text-centered" ] - [ if schoolYear.isActive then - span [ class "tag is-success" ] [ text "Aktiv" ] - - else - span [ class "tag is-light" ] [ text "Inaktiv" ] - ] - , td [ class "has-text-centered" ] - [ if not schoolYear.isActive then - button - [ class "button is-small is-info mr-2" - , onClick (ActivateSchoolYear schoolYear.id) - ] - [ text "Aktivieren" ] - - else - text "" - , button - [ class "button is-small is-danger" - , onClick (DeleteSchoolYear schoolYear.id) - ] - [ text "Löschen" ] - ] - ] - - -viewScheduleList : Model -> Html Msg -viewScheduleList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] - , table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Tag" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [] [ text "Titel" ] - , th [] [ text "Aktion" ] - ] - ] - , tbody [] - (List.map viewScheduleRow model.schedules) - ] - ] - - -viewScheduleForm : Model -> Html Msg -viewScheduleForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Wochentag" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select - [ onInput UpdateNewScheduleDay - , disabled model.isProcessing - , value model.newSchedule.dayOfWeek - ] - [ option [ value "" ] [ text "Wochentag wählen" ] - , option [ value "0" ] [ text "Montag" ] - , option [ value "1" ] [ text "Dienstag" ] - , option [ value "2" ] [ text "Mittwoch" ] - , option [ value "3" ] [ text "Donnerstag" ] - , option [ value "4" ] [ text "Freitag" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.startTime - , onInput UpdateNewScheduleStart - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.endTime - , onInput UpdateNewScheduleEnd - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select - [ onInput UpdateNewScheduleType - , value model.newSchedule.scheduleType - , disabled model.isProcessing - ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Titel" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "z.B. Mathematik" - , value model.newSchedule.title - , onInput UpdateNewScheduleTitle - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary" - , onClick CreateSchedule - , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text " Hinzufügen" - ] - ] - ] - , if String.isEmpty model.newSchedule.dayOfWeek then - div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] - - else - text "" - ] - - -viewScheduleRow : Schedule -> Html Msg -viewScheduleRow schedule = - let - dayName = - case schedule.dayOfWeek of - 0 -> - "Montag" - - 1 -> - "Dienstag" - - 2 -> - "Mittwoch" - - 3 -> - "Donnerstag" - - 4 -> - "Freitag" - - _ -> - "Unbekannt" - - typeName = - if schedule.scheduleType == "break" then - "Pause" - - else - "Unterricht" - in - tr [] - [ td [] [ text dayName ] - , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , td [] [ text typeName ] - , td [] [ text schedule.title ] - , td [] - [ button - [ class "button is-small is-danger" - , onClick (DeleteSchedule schedule.id) - ] - [ text "Löschen" ] - ] - ] - - -viewUserForm : Model -> Html Msg -viewUserForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.newUser.username - , onInput UpdateNewUsername - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.newUser.password - , onInput UpdateNewPassword - ] - [] - ] - ] - ] - , div [ class "column is-narrow" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Admin" ] - , div [ class "control" ] - [ label [ class "checkbox" ] - [ input - [ type_ "checkbox" - , checked model.newUser.isAdmin - , onCheck UpdateNewUserAdmin - ] - [] - , text " Admin-Rechte" - ] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] - ] - ] - ] - - -viewUserList : Model -> Html Msg -viewUserList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Benutzer" ] - , if List.isEmpty model.users then - p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "ID" ] - , th [] [ text "Benutzername" ] - , th [] [ text "Rolle" ] - , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewUserRowWithActions model) model.users) - ] - ] - - -viewUserRowWithActions : Model -> User -> Html Msg -viewUserRowWithActions model user = - if model.editingUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ input - [ class "input is-small" - , type_ "number" - , step "0.5" - , value model.editingUserWorkHours - , onInput UpdateEditUserWorkHours - ] - [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] - ] - ] - - else if model.resetPasswordUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ input - [ class "input is-small" - , type_ "password" - , placeholder "Neues Passwort" - , value model.resetPasswordNew - , onInput UpdateResetPasswordNew - ] - [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] - ] - ] - - else - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - - else - div [] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditUserWorkHours user.id) - ] - [ text "Arbeitszeit" ] - , button - [ class "button is-small is-warning mr-2" - , onClick (ResetUserPassword user.id) - ] - [ text "PW Reset" ] - , button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] - [ text "Löschen" ] - ] - ] - ] - - -viewUserRow : User -> Html Msg -viewUserRow user = - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - - else - button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] - [ text "Löschen" ] - ] - ] - - -viewTimeEntriesList : Model -> Html Msg -viewTimeEntriesList model = - let - filteredEntries = - List.filter - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - in - div [ class "box" ] - [ if List.isEmpty filteredEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithActions model) filteredEntries) - ] - ] - - -viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithActions model entry = - let - hours = - if entry.entryType == "lesson" then - 1.0 - - else - calculateHours entry.startTime entry.endTime - in - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [] - [ div [ class "buttons are-small" ] - [ button - [ class "button is-info is-small" - , onClick (StartEditingTimeEntry entry.id entry) - ] - [ text "Bearbeiten" ] - , button - [ class "button is-danger is-small" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] - [ text "Löschen" ] - ] - ] - ] - - -viewTimeEntriesEditForm : Model -> Html Msg -viewTimeEntriesEditForm model = - div [ class "box has-background-warning-light" ] - [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-success" - , onClick SaveEditTimeEntry - ] - [ text "Speichern" ] - ] - , div [ class "control" ] - [ button - [ class "button is-light" - , onClick CancelEditTimeEntry - ] - [ text "Abbrechen" ] - ] - ] - , viewTimeEntriesListWithEdit model - ] - - -viewTimeEntriesListWithEdit : Model -> Html Msg -viewTimeEntriesListWithEdit model = - div [ class "box" ] - [ if List.isEmpty model.timeEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) - ] - ] - - -viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithEdit model entry = - let - hours = - calculateHours entry.startTime entry.endTime - - isEditing = - model.editingTimeEntryId == Just entry.id - in - if isEditing then - tr [] - [ td [] [ text entry.username ] - , td [] - [ input - [ class "input is-small" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] - [] - ] - , td [] - [ div [ class "field is-grouped" ] - [ div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] - [] - ] - , div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] - [] - ] - ] - ] - , td [] - [ div [ class "select is-small" ] - [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - , td [ class "has-text-right" ] [ text "" ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] - ] - ] - - else - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditTimeEntry entry.id) - ] - [ text "Bearbeiten" ] - , button - [ class "button is-small is-danger" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] - [ text "Löschen" ] - ] - ] - - -viewWeeklyHoursSummary : Model -> Html Msg -viewWeeklyHoursSummary model = - let - filteredHours = - List.filter - (\h -> h.week == model.currentWeek && h.year == model.currentYear) - model.weeklyHours - in - div [ class "box" ] - [ if List.isEmpty filteredHours then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Arbeitet" ] - , th [ class "has-text-right" ] [ text "Soll" ] - , th [ class "has-text-right" ] [ text "Verbleibend" ] - , th [] [ text "Fortschritt" ] - ] - ] - , tbody [] - (List.map viewWeeklyHoursRow filteredHours) - , tfoot [] - [ tr [ class "has-background-light" ] - [ th [] [ text "Gesamt" ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] - , th [] [ text "" ] - , th [] [ text "" ] - ] - ] - ] - ] - - -viewWeeklyHoursRow : WeeklyHours -> Html Msg -viewWeeklyHoursRow hours = - let - progressPercent = - Basics.min 100 (hours.totalHours / hours.targetHours * 100) - - progressColor = - if hours.totalHours >= hours.targetHours then - "is-success" - - else if hours.totalHours >= hours.targetHours * 0.8 then - "is-info" - - else - "is-warning" - in - tr [] - [ td [] [ text hours.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] - , td [] - [ progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [] - ] - ] - - -viewAdminManualEntryForm : Model -> Html Msg -viewAdminManualEntryForm model = - div [ class "box has-background-info-light" ] - [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] - , p [ class "help mb-3" ] - [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] - , div [ class "columns" ] - [ div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Mitarbeiter" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] - (option [ value "" ] [ text "-- Wählen --" ] - :: List.map - (\u -> - option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] - ) - model.users - ) - ] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.adminManualEntryForm.date - , onInput UpdateManualEntryDate - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "number" - , step "0.5" - , placeholder "z.B. 2.5 oder -1.0" - , value model.adminManualEntryForm.hours - , onInput UpdateManualEntryHours - ] - [] - ] - , p [ class "help" ] - [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-info" - , onClick SaveAdminTimeEntry - , disabled - (case model.adminManualEntryForm.selectedUserId of - Just _ -> - model.isProcessing || String.isEmpty model.adminManualEntryForm.hours - - Nothing -> - True - ) - ] - [ text "Eintrag erstellen" ] - ] - ] - ] - - -viewYearlyHoursSummary : Model -> Html Msg -viewYearlyHoursSummary model = - div [ class "box" ] - [ div [ class "level mb-4" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ a - [ class "button is-info" - , onClick DownloadYearlySummaryPDF - , disabled model.isProcessing - ] - [ span [ class "icon" ] - [ i [ class "fas fa-file-pdf" ] [] ] - , span [] - [ text - (if model.isProcessing then - "Wird erstellt..." - - else - "PDF exportieren" - ) - ] - ] - ] - ] - ] - , if List.isEmpty model.yearlyHoursSummary then - p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] - , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] - , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] - , th [ class "has-text-centered" ] [ text "Status" ] - ] - ] - , tbody [] - (List.map viewYearlyHourRow model.yearlyHoursSummary) - ] - ] - - -viewYearlyHourRow : YearlyHoursSummary -> Html Msg -viewYearlyHourRow summary = - let - statusClass = - if summary.remainingYearly > 0 then - "has-text-danger" - - else if abs summary.remainingYearly < 0.5 then - "has-text-success" - - else - "has-text-warning" - in - tr [] - [ td [] [ text summary.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] - , td [ class ("has-text-centered " ++ statusClass) ] - [ if summary.remainingYearly > 0 then - text ("Offen: " ++ String.fromFloat summary.remainingYearly) - - else if summary.remainingYearly < -0.5 then - text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) - - else - text "✓ Erfüllt" - ] - ] diff --git a/frontend/src/View/Components/Navigation.elm b/frontend/src/View/Components/Navigation.elm deleted file mode 100644 index ba3895d..0000000 --- a/frontend/src/View/Components/Navigation.elm +++ /dev/null @@ -1,99 +0,0 @@ -module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Types.Model exposing (Model, Schedule) -import Types.Msg exposing (Msg(..)) -import View.Components.Schedule exposing (viewScheduleItemWithDay) - - -viewWeekNavigation : Model -> Html Msg -viewWeekNavigation model = - let - dateRange = - case model.weekDates of - Just wd -> - wd.range - - Nothing -> - "Laden..." - in - div [ class "box" ] - [ nav [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick PreviousWeek - ] - [ span [ class "icon" ] - [ i [ class "fas fa-chevron-left" ] [] ] - , span [] [ text "Vorherige Woche" ] - ] - ] - ] - , div [ class "level-item" ] - [ div - [ style "display" "flex" - , style "flex-direction" "column" - , style "align-items" "center" - , style "gap" "0.5rem" - , style "min-width" "250px" - ] - [ p - [ class "heading" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text "Kalenderwoche" ] - , p - [ class "title is-3" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] - , p - [ class "subtitle is-6" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text dateRange ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick NextWeek - ] - [ span [] [ text "Nächste Woche" ] - , span [ class "icon" ] - [ i [ class "fas fa-chevron-right" ] [] ] - ] - ] - ] - ] - ] - - -viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg -viewDayMobile model dayName ( dayOfWeek, schedules ) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - - Nothing -> - "Laden..." - in - div [ class "box mb-4" ] - [ p [ class "has-text-weight-bold has-text-centered mb-3" ] - [ text (dayName ++ " - " ++ dateForDay) ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] diff --git a/frontend/src/View/Components/Schedule.elm b/frontend/src/View/Components/Schedule.elm deleted file mode 100644 index 57730bb..0000000 --- a/frontend/src/View/Components/Schedule.elm +++ /dev/null @@ -1,76 +0,0 @@ -module View.Components.Schedule exposing (viewScheduleItemWithDay) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Types.Model exposing (Model, Schedule) -import Types.Msg exposing (Msg(..)) - - -viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg -viewScheduleItemWithDay model dayOfWeek schedule = - let - isSelected = - List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries - - isClickable = - (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing - - boxClass = - if isSelected then - "box has-background-success-light" - - else if isClickable then - "box has-background-white" - - else - "box has-background-light" - - typeText = - if schedule.scheduleType == "break" then - " (Pause)" - - else - "" - - cursorStyle = - if isClickable then - "pointer" - - else - "not-allowed" - - opacity = - if isClickable || isSelected then - "1" - - else - "0.6" - in - div - [ class boxClass - , onClick - (if isClickable then - ToggleScheduleSelection schedule.id dayOfWeek - - else - FetchSchedules - ) - , style "cursor" cursorStyle - , style "margin-bottom" "0.5rem" - , style "padding" "0.75rem" - , style "opacity" opacity - , style "transition" "all 0.2s ease" - , style "border" - (if isClickable && not isSelected then - "2px solid transparent" - - else - "2px solid currentColor" - ) - ] - [ p [ class "has-text-weight-bold is-size-7" ] - [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [ class "is-size-7" ] - [ text (schedule.title ++ typeText) ] - ] diff --git a/frontend/src/View/Components/Toast.elm b/frontend/src/View/Components/Toast.elm deleted file mode 100644 index e55d2fe..0000000 --- a/frontend/src/View/Components/Toast.elm +++ /dev/null @@ -1,66 +0,0 @@ -module View.Components.Toast exposing (viewToasts) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Types.Model exposing (Model, Schedule, Toast, ToastType(..)) -import Types.Msg exposing (Msg(..)) -import Utils.TimeUtils exposing (calculateHours) -import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) -import View.Components.Schedule exposing (viewScheduleItemWithDay) - - -viewToasts : List Toast -> Html Msg -viewToasts toasts = - div [ class "toast-container" ] - (List.map viewToast toasts) - - -viewToast : Toast -> Html Msg -viewToast toast = - let - toastClass = - case toast.toastType of - ErrorToast -> - "toast-error" - - SuccessToast -> - "toast-success" - - InfoToast -> - "toast-info" - - WarningToast -> - "toast-warning" - - icon = - case toast.toastType of - ErrorToast -> - "fas fa-exclamation-circle" - - SuccessToast -> - "fas fa-check-circle" - - InfoToast -> - "fas fa-info-circle" - - WarningToast -> - "fas fa-exclamation-triangle" - in - div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] - [ div [ class "toast-content" ] - [ span [ class "toast-icon" ] - [ i [ class icon ] [] ] - , span [ class "toast-message" ] [ text toast.message ] - ] - , if toast.dismissible then - button - [ class "toast-close" - , onClick (DismissToast toast.id) - , attribute "aria-label" "Schließen" - ] - [ i [ class "fas fa-times" ] [] ] - - else - text "" - ] diff --git a/frontend/src/View/Login.elm b/frontend/src/View/Login.elm deleted file mode 100644 index 9ed2485..0000000 --- a/frontend/src/View/Login.elm +++ /dev/null @@ -1,57 +0,0 @@ -module View.Login exposing (viewLogin) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Types.Model exposing (Model) -import Types.Msg exposing (Msg(..)) - - -viewLogin : Model -> Html Msg -viewLogin model = - section [ class "section" ] - [ div [ class "container" ] - [ div [ class "columns is-centered" ] - [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] - [ div [ class "box" ] - [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.username - , onInput UpdateUsername - ] - [] - ] - ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.password - , onInput UpdatePassword - ] - [] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-fullwidth" - , onClick Login - ] - [ text "Anmelden" ] - ] - ] - ] - ] - ] - ] - ] diff --git a/frontend/src/View/UserDashboard.elm b/frontend/src/View/UserDashboard.elm deleted file mode 100644 index 60fac13..0000000 --- a/frontend/src/View/UserDashboard.elm +++ /dev/null @@ -1,338 +0,0 @@ -module View.UserDashboard exposing (viewUserDashboard) - -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Types.Model exposing (Model, Schedule) -import Types.Msg exposing (Msg(..)) -import Utils.TimeUtils exposing (calculateHours) -import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) -import View.Components.Schedule exposing (viewScheduleItemWithDay) - - -viewUserDashboard : Model -> Html Msg -viewUserDashboard model = - div [] - [ nav [ class "navbar is-primary" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] - ] - , a - [ class - ("navbar-burger" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - , attribute "role" "navigation" - , attribute "aria-label" "menu" - , attribute "aria-expanded" - (if model.mobileMenuOpen then - "true" - - else - "false" - ) - , onClick ToggleMobileMenu - ] - [ span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - ] - ] - , div - [ id "navbarUser" - , class - ("navbar-menu" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-2" ] [ text model.username ] - ] - , div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] - [ span [ class "icon" ] - [ i [ class "fas fa-sign-out-alt" ] [] ] - , span [] [ text "Abmelden" ] - ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ viewWeekNavigation model - , h2 [ class "title" ] [ text "Stundenplan" ] - , if model.hasEntriesForCurrentWeek && not model.weekEditMode then - div [ class "notification is-success" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-check-circle" ] [] ] - , span [] [ text "Diese Woche wurde bereits erfasst" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-warning" - , onClick EnableEditMode - , disabled model.isProcessing - ] - [ text "Bearbeiten" ] - ] - ] - ] - ] - - else if model.weekEditMode then - div [ class "notification is-warning" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-edit" ] [] ] - , span [] [ text "Bearbeitungsmodus aktiv" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-danger is-small mr-2" - , onClick DeleteWeekEntries - , disabled model.isProcessing - ] - [ text "Einträge löschen" ] - , button - [ class "button is-light is-small" - , onClick DisableEditMode - ] - [ text "Abbrechen" ] - ] - ] - ] - ] - - else - div [ class "notification is-info is-light" ] - [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] - , viewScheduleGridWithWeek model - , if not model.hasEntriesForCurrentWeek || model.weekEditMode then - div [ class "field mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-large is-fullwidth" - , onClick SaveTimeEntries - , disabled (List.isEmpty model.selectedEntries || model.isProcessing) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text - (if model.weekEditMode then - "Änderungen speichern" - - else - "Speichern" - ) - ] - ] - ] - - else - text "" - , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] - , viewUserYearlyTotal model - ] - ] - ] - - -viewScheduleGridWithWeek : Model -> Html Msg -viewScheduleGridWithWeek model = - let - days = - [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ] - - groupedSchedules = - List.range 0 4 - |> List.map - (\day -> - ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) - ) - in - div [] - [ div [ class "is-hidden-mobile" ] - [ div [ class "table-container" ] - [ table [ class "table is-bordered is-fullwidth" ] - [ thead [] - [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) - ] - , tbody [] - [ tr [] - (List.map (viewDayColumnWithWeek model) groupedSchedules) - ] - ] - ] - ] - , div [ class "is-hidden-tablet" ] - (List.map2 (viewDayMobile model) days groupedSchedules) - ] - - -viewUserYearlyTotal : Model -> Html Msg -viewUserYearlyTotal model = - let - yearlyTotal = - model.timeEntries - |> List.map - (\entry -> - if entry.entryType == "lesson" then - 1.0 - - else - Utils.TimeUtils.calculateHours entry.startTime entry.endTime - ) - |> List.sum - - userTarget = - List.filter (\u -> not u.isAdmin) model.users - |> List.head - |> Maybe.map .yearlyWorkHours - |> Maybe.withDefault 60 - - remaining = - userTarget - yearlyTotal - - progressPercent = - Basics.min 100 (yearlyTotal / userTarget * 100) - - progressColor = - if remaining <= 0 then - "is-success" - - else if yearlyTotal >= userTarget * 0.8 then - "is-info" - - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Jahresenziel" ] - , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Geleistete Stunden" ] - , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Restliche Stunden" ] - , p - [ class - ("title is-4 " - ++ (if remaining <= 0 then - "has-text-success" - - else - "has-text-warning" - ) - ) - ] - [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - -viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg -viewDayColumnWithWeek model ( dayOfWeek, schedules ) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - - Nothing -> - "Laden..." - in - td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] - [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] - [ text dateForDay ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] - - -viewUserWeeklySummary : Model -> Html Msg -viewUserWeeklySummary model = - case model.userWeeklySummary of - Just summary -> - let - progressPercent = - Basics.min 100 (summary.totalHours / summary.targetHours * 100) - - progressColor = - if summary.totalHours >= summary.targetHours then - "is-success" - - else if summary.totalHours >= summary.targetHours * 0.8 then - "is-info" - - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] - , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] - , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Verbleibend" ] - , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ] - [ text (String.fromFloat summary.remainingHours ++ " Std.") ] - , if summary.remainingHours < 0 then - p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] - - else - p [ class "subtitle is-6" ] [ text "" ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - Nothing -> - div [ class "box" ] - [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] - ] diff --git a/frontend/src/View/View.elm b/frontend/src/View/View.elm deleted file mode 100644 index c16d910..0000000 --- a/frontend/src/View/View.elm +++ /dev/null @@ -1,29 +0,0 @@ -module View.View exposing (view) - -import Html exposing (Html, div) -import Html.Attributes exposing (class) -import Types.Model exposing (Model) -import Types.Msg exposing (Msg(..)) -import Types.Page exposing (Page(..)) -import View.AdminDashboard exposing (viewAdminDashboard) -import View.Components.Toast exposing (viewToasts) -import View.Login exposing (viewLogin) -import View.UserDashboard exposing (viewUserDashboard) - - -view : Model -> Html Msg -view model = - div [ class "app-container" ] - [ viewToasts model.toasts - , div [ class "container" ] - [ case model.page of - LoginPage -> - viewLogin model - - UserDashboard -> - viewUserDashboard model - - AdminDashboard -> - viewAdminDashboard model - ] - ]