diff --git a/backend/database.go b/backend/database.go index 4da250c..123adee 100644 --- a/backend/database.go +++ b/backend/database.go @@ -31,12 +31,12 @@ func InitDB(filepath string) *sql.DB { func createTables(db *sql.DB) { queries := []string{ `CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - is_admin BOOLEAN NOT NULL DEFAULT 0, - weekly_hours REAL NOT NULL DEFAULT 40.0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT 0, + yearly_hours REAL NOT NULL DEFAULT 1800.0, -- 40 Stunden/Woche * 45 Schulwochen + created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -74,10 +74,9 @@ func createTables(db *sql.DB) { } } - // Admin-User anlegen hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) _, err := db.Exec(` - INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours) + INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?, ?)`, 1, "admin", string(hash), true, 40.0, ) @@ -104,8 +103,8 @@ func createIndexes(db *sql.DB) { func GetUserByUsername(db *sql.DB, username string) (*User, error) { user := &User{} - err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE username = ?", username). - Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.WeeklyHours) + err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE username = ?", username). + Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours) if err != nil { return nil, err } @@ -114,22 +113,22 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) { func GetUserByID(db *sql.DB, userID int) (*User, error) { user := &User{} - err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE id = ?", userID). - Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.WeeklyHours) + err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE id = ?", userID). + Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours) if err != nil { return nil, err } return user, nil } -func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, weeklyHours float64) error { - _, err := db.Exec("INSERT INTO users (username, password, is_admin, weekly_hours) VALUES (?, ?, ?, ?)", - username, hashedPassword, isAdmin, weeklyHours) +func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, yearlyHours float64) error { + _, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)", + username, hashedPassword, isAdmin, yearlyHours) return err } func GetAllUsers(db *sql.DB) ([]User, error) { - rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users ORDER BY username") + rows, err := db.Query("SELECT id, username, is_admin, yearly_hours FROM users ORDER BY username") if err != nil { return nil, err } @@ -138,7 +137,7 @@ func GetAllUsers(db *sql.DB) ([]User, error) { var users []User for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.WeeklyHours); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.YearlyHours); err != nil { continue } users = append(users, u) @@ -146,9 +145,9 @@ func GetAllUsers(db *sql.DB) ([]User, error) { return users, nil } -func UpdateUser(db *sql.DB, userID int, weeklyHours float64) error { - _, err := db.Exec("UPDATE users SET weekly_hours = ? WHERE id = ?", - weeklyHours, userID) +func UpdateUser(db *sql.DB, userID int, yearlyHours float64) error { + _, err := db.Exec("UPDATE users SET yearly_hours = ? WHERE id = ?", + yearlyHours, userID) return err } @@ -213,10 +212,10 @@ func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { rows, err := db.Query(` - SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username + SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username FROM time_entries te JOIN users u ON te.user_id = u.id - WHERE te.user_id = ? + WHERE te.user_id = ? ORDER BY te.date DESC, te.created_at DESC `, userID) if err != nil { @@ -237,7 +236,7 @@ func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { rows, err := db.Query(` - SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username + SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username FROM time_entries te JOIN users u ON te.user_id = u.id ORDER BY te.date DESC, te.created_at DESC @@ -260,34 +259,37 @@ func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { rows, err := db.Query(` - SELECT - te.user_id, - u.username, - te.date, - te.start_time, - te.end_time, - te.type, - u.weekly_hours - FROM time_entries te - JOIN users u ON te.user_id = u.id - ORDER BY te.date DESC - `) + SELECT + te.user_id, + u.username, + te.date, + te.start_time, + te.end_time, + te.type, + u.yearly_hours + FROM time_entries te + JOIN users u ON te.user_id = u.id + ORDER BY te.date DESC + `) if err != nil { return nil, err } defer rows.Close() hoursMap := make(map[string]*WeeklyHours) + userYearlyHours := make(map[int]float64) for rows.Next() { var userID int var username, dateStr, startTime, endTime, entryType string - var expectedWeeklyHours float64 + var yearlyHours float64 - if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &expectedWeeklyHours); err != nil { + if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &yearlyHours); err != nil { continue } + userYearlyHours[userID] = yearlyHours + t, err := time.Parse("2006-01-02", dateStr) if err != nil { continue @@ -303,24 +305,30 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { } key := fmt.Sprintf("%d_%d_%d", userID, year, week) - if existing, exists := hoursMap[key]; exists { existing.TotalHours += hours } else { hoursMap[key] = &WeeklyHours{ - UserID: userID, - Username: username, - Year: year, - Week: week, - TotalHours: hours, - ExpectedHours: expectedWeeklyHours, - RemainingHours: expectedWeeklyHours - hours, + UserID: userID, + Username: username, + Year: year, + Week: week, + TotalHours: hours, } } } + yearlyTotals := make(map[int]float64) for _, h := range hoursMap { - h.RemainingHours = h.ExpectedHours - h.TotalHours + yearlyTotals[h.UserID] += h.TotalHours + } + + for _, h := range hoursMap { + h.YearlyTarget = userYearlyHours[h.UserID] + h.YearlyActual = yearlyTotals[h.UserID] + + h.WeeklyTarget = h.YearlyTarget / 45.0 + h.RemainingYearly = h.YearlyTarget - h.YearlyActual } var result []WeeklyHours @@ -341,6 +349,57 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { return result, nil } +func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) { + users, err := GetAllUsers(db) + if err != nil { + return nil, err + } + + entries, err := GetAllTimeEntries(db) + if err != nil { + return nil, err + } + + userTotals := make(map[int]float64) + usernames := make(map[int]string) + + for _, entry := range entries { + var hours float64 + if entry.Type == "lesson" { + hours = 1.0 + } else { + hours = calculateHoursDiff(entry.StartTime, entry.EndTime) + } + userTotals[entry.UserID] += hours + usernames[entry.UserID] = entry.Username + } + + var result []WeeklyHours + for _, user := range users { + if !user.IsAdmin { + total := userTotals[user.ID] + remaining := user.YearlyHours - total + + result = append(result, WeeklyHours{ + UserID: user.ID, + Username: user.Username, + Year: time.Now().Year(), + Week: 0, + TotalHours: total, + YearlyTarget: user.YearlyHours, + YearlyActual: total, + RemainingYearly: remaining, + }) + } + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Username < result[j].Username + }) + + return result, nil +} + func calculateHoursDiff(startTime, endTime string) float64 { parseTime := func(timeStr string) float64 { parts := strings.Split(timeStr, ":") @@ -376,8 +435,8 @@ func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) } query := ` - DELETE FROM time_entries - WHERE user_id = ? + DELETE FROM time_entries + WHERE user_id = ? AND date IN (?, ?, ?, ?, ?) ` _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]) @@ -393,9 +452,9 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo } query := ` - SELECT COUNT(*) - FROM time_entries - WHERE user_id = ? + SELECT COUNT(*) + FROM time_entries + WHERE user_id = ? AND date IN (?, ?, ?, ?, ?) ` diff --git a/backend/go.mod b/backend/go.mod index c45ed46..859ee3c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.25.3 require ( github.com/labstack/echo/v4 v4.13.4 golang.org/x/crypto v0.43.0 + golang.org/x/time v0.11.0 modernc.org/sqlite v1.40.0 ) @@ -22,7 +23,6 @@ require ( golang.org/x/net v0.45.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect - golang.org/x/time v0.11.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/handlers.go b/backend/handlers.go index 14fd4cd..cd5713c 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -14,7 +14,6 @@ type App struct { DB *sql.DB } -// Login Handler func (app *App) LoginHandler(c echo.Context) error { var req LoginRequest if err := c.Bind(&req); err != nil { @@ -44,7 +43,6 @@ func (app *App) LoginHandler(c echo.Context) error { return c.JSON(http.StatusOK, response) } -// Schedule Handlers func (app *App) GetSchedulesHandler(c echo.Context) error { schedules, err := GetAllSchedules(app.DB) if err != nil { @@ -76,33 +74,62 @@ func (app *App) DeleteScheduleHandler(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } -// // User Handlers -// func (app *App) CreateUserHandler(c echo.Context) error { -// var req CreateUserRequest -// if err := c.Bind(&req); err != nil { -// return echo.NewHTTPError(http.StatusBadRequest, "invalid request") -// } +func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { + hours, err := GetYearlyHoursSummary(app.DB) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if hours == nil { + hours = []WeeklyHours{} + } + return c.JSON(http.StatusOK, hours) +} -// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) -// if err != nil { -// return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password") -// } +func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { + isAdmin, _ := c.Get("is_admin").(bool) -// if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { -// return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) -// } + if !isAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Only admins can create entries for others") + } -// return c.JSON(http.StatusCreated, map[string]string{"message": "user created"}) -// } + var req struct { + UserID int `json:"user_id"` + Date string `json:"date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Type string `json:"type"` + } + + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + } + + entry := TimeEntry{ + UserID: req.UserID, + Date: req.Date, + StartTime: req.StartTime, + EndTime: req.EndTime, + Type: req.Type, + } + + if err := CreateTimeEntry(app.DB, &entry); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) +} func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + if users == nil { + users = []User{} + } return c.JSON(http.StatusOK, users) } @@ -116,10 +143,9 @@ func (app *App) DeleteUserHandler(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } -// Time Entry Handlers func (app *App) CreateTimeEntryHandler(c echo.Context) error { userID := c.Get("user_id").(int) @@ -144,6 +170,9 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + if entries == nil { + entries = []TimeEntry{} + } return c.JSON(http.StatusOK, entries) } @@ -189,6 +218,9 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + if entries == nil { + entries = []TimeEntry{} + } return c.JSON(http.StatusOK, entries) } @@ -197,6 +229,9 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } + if hours == nil { + hours = []WeeklyHours{} + } return c.JSON(http.StatusOK, hours) } @@ -217,7 +252,7 @@ func (app *App) DeleteWeekEntries(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } type WeekDates struct { @@ -320,7 +355,7 @@ func (app *App) UpdateUserHandler(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - if err := UpdateUser(app.DB, userID, req.WeeklyHours); err != nil { + if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -378,47 +413,18 @@ func (app *App) DeleteTimeEntryHandler(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } -func (app *App) GetMyWeeklySummaryHandler(c echo.Context) error { +func (app *App) GetMyInfoHandler(c echo.Context) error { userID := c.Get("user_id").(int) - year, err := strconv.Atoi(c.QueryParam("year")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") - } - - week, err := strconv.Atoi(c.QueryParam("week")) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") - } - user, err := GetUserByID(app.DB, userID) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - allHours, err := GetWeeklyHours(app.DB) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - for _, h := range allHours { - if h.UserID == userID && h.Year == year && h.Week == week { - return c.JSON(http.StatusOK, h) - } - } - - return c.JSON(http.StatusOK, WeeklyHours{ - UserID: userID, - Username: user.Username, - Year: year, - Week: week, - TotalHours: 0, - ExpectedHours: user.WeeklyHours, - RemainingHours: user.WeeklyHours, - }) + return c.JSON(http.StatusOK, user) } func (app *App) CreateUserHandler(c echo.Context) error { @@ -432,11 +438,11 @@ func (app *App) CreateUserHandler(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") } - if req.WeeklyHours == 0 { - req.WeeklyHours = 40.0 + if req.YearlyHours == 0 { + req.YearlyHours = 1800.0 } - if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.WeeklyHours); err != nil { + if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } diff --git a/backend/main.go b/backend/main.go index 762ed67..f09a17a 100644 --- a/backend/main.go +++ b/backend/main.go @@ -44,7 +44,8 @@ func main() { protected.DELETE("/my-time-entries/week", app.DeleteWeekEntries) protected.GET("/week-dates", app.GetWeekDates) protected.GET("/week-has-entries", app.CheckWeekHasEntries) - protected.GET("/my-weekly-summary", app.GetMyWeeklySummaryHandler) + protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler) + protected.GET("/my-info", app.GetMyInfoHandler) } admin := e.Group("/api/admin") @@ -59,9 +60,10 @@ func main() { admin.GET("/time-entries", app.GetAllTimeEntriesHandler) admin.GET("/weekly-hours", app.GetWeeklyHoursHandler) admin.PUT("/users/:id", app.UpdateUserHandler) - admin.POST("/users/:id/reset-password", app.ResetPasswordHandler) + admin.PUT("/users/:id/reset-password", app.ResetPasswordHandler) admin.PUT("/time-entries/:id", app.UpdateTimeEntryHandler) admin.DELETE("/time-entries/:id", app.DeleteTimeEntryHandler) + admin.POST("/time-entry", app.AdminCreateTimeEntryHandler) } e.Static("/", "./static") diff --git a/backend/models.go b/backend/models.go index 085c4ef..6ca8f71 100644 --- a/backend/models.go +++ b/backend/models.go @@ -15,13 +15,15 @@ type TimeEntry struct { } type WeeklyHours struct { - UserID int `json:"user_id"` - Username string `json:"username"` - Week int `json:"week"` - Year int `json:"year"` - TotalHours float64 `json:"total_hours"` - ExpectedHours float64 `json:"expected_hours"` - RemainingHours float64 `json:"remaining_hours"` + UserID int `json:"user_id"` + Username string `json:"username"` + Week int `json:"week"` + Year int `json:"year"` + TotalHours float64 `json:"total_hours"` + YearlyTarget float64 `json:"yearly_target"` // NEU + YearlyActual float64 `json:"yearly_actual"` // NEU + WeeklyTarget float64 `json:"weekly_target"` // NEU + RemainingYearly float64 `json:"remaining_yearly"` // NEU } type User struct { @@ -29,7 +31,7 @@ type User struct { Username string `json:"username"` Password string `json:"-"` IsAdmin bool `json:"is_admin"` - WeeklyHours float64 `json:"weekly_hours"` + YearlyHours float64 `json:"yearly_hours"` } type Schedule struct { @@ -56,12 +58,12 @@ type CreateUserRequest struct { Username string `json:"username" validate:"required"` Password string `json:"password" validate:"required,min=6"` IsAdmin bool `json:"is_admin"` - WeeklyHours float64 `json:"weekly_hours"` + YearlyHours float64 `json:"yearly_hours"` } type UpdateUserRequest struct { Username string `json:"username"` - WeeklyHours float64 `json:"weekly_hours"` + YearlyHours float64 `json:"yearly_hours"` } type ResetPasswordRequest struct { diff --git a/docker-compose.yml b/docker-compose.yml index 16d47f0..221d016 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,3 @@ -# version: '3.8' - -# services: -# timetracking: -# build: . -# container_name: school-timetracking -# ports: -# - "8080:8080" -# volumes: -# - ./data:/data -# environment: -# - PORT=8080 -# - DB_PATH=/data/timetracking.db -# restart: unless-stopped services: timetracking: build: . @@ -21,6 +7,8 @@ services: environment: - PORT=8080 - DB_PATH=/data/timetracking.db + - JWT_SECRET=your-default-secret-change-me + - TZ=Europe/Berlin # Optional: Zeitzone volumes: - timetracking-data:/data restart: unless-stopped @@ -34,4 +22,3 @@ volumes: networks: timetracking-net: driver: bridge - diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index c7716ce..746031f 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,26 +1,37 @@ port module Main exposing (..) import Browser +import Dict exposing (Dict) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Http -import Json.Decode as Decode exposing (Decoder, field, int, string, bool, list, float) +import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) import Json.Encode as Encode import Task import Time -import Dict exposing (Dict) + -- PORTS + port saveToken : Encode.Value -> Cmd msg + + port removeToken : () -> Cmd msg + + port confirmDelete : String -> Cmd msg + + port confirmDeleteResponse : (Bool -> msg) -> Sub msg + + -- MAIN + main : Program Flags Model Msg main = Browser.element @@ -30,14 +41,21 @@ main = , view = view } + + -- FLAGS + + type alias Flags = { token : Maybe String , isAdmin : Bool } + + -- MODEL + type alias Model = { page : Page , activeTab : AdminTab @@ -49,6 +67,7 @@ type alias Model = , users : List User , timeEntries : List TimeEntry , weeklyHours : List WeeklyHours + , yearlyHoursSummary : List YearlyHoursSummary , selectedEntries : List SelectedEntry , currentWeek : Int , currentYear : Int @@ -73,18 +92,22 @@ type alias Model = , userPasswordInput : String , isProcessing : Bool , mobileMenuOpen : Bool + , adminManualEntryForm : AdminManualEntry } + type Page = LoginPage | UserDashboard | AdminDashboard + type AdminTab = ScheduleTab | UsersTab | TimeEntriesTab + type alias Schedule = { id : Int , dayOfWeek : Int @@ -94,13 +117,15 @@ type alias Schedule = , title : String } + type alias User = { id : Int , username : String , isAdmin : Bool - , weeklyWorkHours : Float + , yearlyWorkHours : Float } + type alias TimeEntry = { id : Int , userId : Int @@ -112,11 +137,13 @@ type alias TimeEntry = , endTime : String } + type alias SelectedEntry = { scheduleId : Int , dayOfWeek : Int } + type alias NewSchedule = { dayOfWeek : String , startTime : String @@ -125,19 +152,22 @@ type alias NewSchedule = , title : String } + type alias NewUser = { username : String , password : String , isAdmin : Bool } + type alias WeekDates = { year : Int , week : Int - , dates : List (String, String) + , dates : List ( String, String ) , range : String } + type alias WeeklySummary = { userId : Int , username : String @@ -148,6 +178,7 @@ type alias WeeklySummary = , remainingHours : Float } + type alias EditingTimeEntry = { entryId : Int , date : String @@ -156,6 +187,7 @@ type alias EditingTimeEntry = , entryType : String } + type alias WeeklyHours = { userId : Int , username : String @@ -166,16 +198,44 @@ type alias WeeklyHours = , remainingHours : Float } -init : Flags -> (Model, Cmd Msg) + +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 + , startTime : String + , endTime : String + , entryType : String + } + + +init : Flags -> ( Model, Cmd Msg ) init flags = let - initialPage = + initialPage = case flags.token of Just _ -> - if flags.isAdmin then AdminDashboard else UserDashboard + if flags.isAdmin then + AdminDashboard + + else + UserDashboard + Nothing -> LoginPage - + model = { page = initialPage , activeTab = ScheduleTab @@ -187,6 +247,7 @@ init flags = , users = [] , timeEntries = [] , weeklyHours = [] + , yearlyHoursSummary = [] , selectedEntries = [] , currentWeek = 1 , currentYear = 2025 @@ -211,22 +272,33 @@ init flags = , userPasswordInput = "" , isProcessing = False , mobileMenuOpen = False + , adminManualEntryForm = AdminManualEntry Nothing "" "" "" "lesson" } - - cmd = + + cmd = case flags.token of Just token -> - Cmd.batch + Cmd.batch [ Task.perform SetTime Time.now , fetchSchedules (Just token) + , fetchYearlyHoursSummary token + , if flags.isAdmin then + Cmd.none + + else + fetchMyInfo token ] + Nothing -> Task.perform SetTime Time.now in - (model, cmd) + ( model, cmd ) + + -- UPDATE + type Msg = UpdateUsername String | UpdatePassword String @@ -270,11 +342,12 @@ type Msg | 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) - | FetchMyWeeklySummary | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) | EditTimeEntry Int | CancelEditTimeEntry @@ -312,993 +385,1438 @@ type Msg | UserPasswordSaved (Result Http.Error ()) | ToggleMobileMenu | CloseMobileMenu + | SelectUserForManualEntry Int + | UpdateManualEntryDate String + | UpdateManualEntryStartTime String + | UpdateManualEntryEndTime String + | UpdateManualEntryType String + | SaveAdminTimeEntry + | AdminTimeEntrySaved (Result Http.Error ()) + | FetchMyInfo + | MyInfoReceived (Result Http.Error User) -update : Msg -> Model -> (Model, Cmd Msg) + +update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of ToggleMobileMenu -> - ({ model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none) - + ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) + CloseMobileMenu -> - ({ model | mobileMenuOpen = False }, Cmd.none) - + ( { model | mobileMenuOpen = False }, Cmd.none ) + UpdateUsername username -> - ({ model | username = username }, Cmd.none) + ( { model | username = username }, Cmd.none ) UpdatePassword password -> - ({ model | password = password }, Cmd.none) + ( { model | password = password }, Cmd.none ) Login -> if model.isProcessing then - (model, Cmd.none) + ( model, Cmd.none ) + else - ({ model | isProcessing = True }, loginRequest model.username model.password) + ( { 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) - ] + 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 + ( { model | token = Just result.token , username = result.username , isAdmin = result.isAdmin , page = newPage , error = Nothing , isProcessing = False - }, Cmd.batch + } + , Cmd.batch [ saveToken tokenData , fetchSchedules (Just result.token) - , if not result.isAdmin then - Cmd.batch + , if not result.isAdmin then + Cmd.batch [ fetchMyTimeEntries result.token , fetchWeekDates result.token year week , checkWeekHasEntries result.token year week - , fetchMyWeeklySummary result.token year week + , fetchYearlyHoursSummary result.token + , fetchMyInfo result.token ] - else - Cmd.batch + + else + Cmd.batch [ fetchMyTimeEntries result.token , fetchWeekDates result.token year week , checkWeekHasEntries result.token year week - , fetchMyWeeklySummary result.token year week + , fetchYearlyHoursSummary result.token ] - ]) + ] + ) LoginResponse (Err _) -> - ({ model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none) + ( { model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none ) Logout -> - ({ model + ( { model | page = LoginPage , token = Nothing , isAdmin = False , username = "" , password = "" , isProcessing = False - }, removeToken ()) + } + , removeToken () + ) FetchSchedules -> - (model, fetchSchedules model.token) + ( model, fetchSchedules model.token ) SchedulesReceived (Ok schedules) -> - ({ model | schedules = schedules }, Cmd.none) + ( { model | schedules = schedules }, Cmd.none ) SchedulesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none) + ( { model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none ) ToggleScheduleSelection scheduleId dayOfWeek -> let - entry = { scheduleId = scheduleId, dayOfWeek = dayOfWeek } + 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) + ( { 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) + ( { model | error = Nothing } + , saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) TimeEntriesSaved (Ok _) -> case model.token of Just token -> - ({ model - | selectedEntries = [] - , error = Nothing + ( { model + | error = Nothing , weekEditMode = False , hasEntriesForCurrentWeek = True - }, Cmd.batch + } + , Cmd.batch [ fetchMyTimeEntries token - , fetchMyWeeklySummary token model.currentYear model.currentWeek - ]) + ] + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) TimeEntriesSaved (Err _) -> - ({ model | error = Just "Fehler beim Speichern" }, Cmd.none) + ( { model | error = Just "Fehler beim Speichern" }, Cmd.none ) PreviousWeek -> let - (newYear, newWeek) = previousWeek model.currentYear model.currentWeek + ( newYear, newWeek ) = + previousWeek model.currentYear model.currentWeek in - ({ model + ( { model | currentWeek = newWeek , currentYear = newYear , selectedEntries = [] , weekEditMode = False - }, case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - , fetchMyWeeklySummary token newYear newWeek - ] - Nothing -> - Cmd.none - ) + } + , 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 + ( newYear, newWeek ) = + nextWeek model.currentYear model.currentWeek in - ({ model + ( { model | currentWeek = newWeek , currentYear = newYear , selectedEntries = [] , weekEditMode = False - }, case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - , fetchMyWeeklySummary token newYear newWeek - ] - Nothing -> - Cmd.none - ) + } + , 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) + ( model, fetchWeekDates token model.currentYear model.currentWeek ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) WeekDatesReceived (Ok weekDates) -> - ({ model | weekDates = Just weekDates }, Cmd.none) + ( { model | weekDates = Just weekDates }, Cmd.none ) WeekDatesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none) + ( { model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none ) CheckWeekHasEntries -> case model.token of Just token -> - (model, checkWeekHasEntries token model.currentYear model.currentWeek) + ( model, checkWeekHasEntries token model.currentYear model.currentWeek ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) WeekHasEntriesReceived (Ok hasEntries) -> - ({ model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none) + ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) WeekHasEntriesReceived (Err _) -> - (model, Cmd.none) + ( model, Cmd.none ) SetTime time -> let - (year, week) = getISOWeekFromPosix time - - cmds = case model.token of - Just token -> - if model.page == UserDashboard || model.page == LoginPage then - Cmd.batch - [ checkWeekHasEntries token year week - , fetchWeekDates token year week - , fetchMyTimeEntries token - , fetchMyWeeklySummary token year week - ] - else + ( 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 - Nothing -> - Cmd.none in - ({ model + ( { model | currentTime = time , currentWeek = week , currentYear = year - }, cmds) + } + , 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 + 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 + ( { model | weekEditMode = True , selectedEntries = preSelectedEntries - }, Cmd.none) + } + , Cmd.none + ) DisableEditMode -> - ({ model + ( { model | weekEditMode = False - , selectedEntries = [] - }, Cmd.none) + } + , Cmd.none + ) DeleteWeekEntries -> case model.token of Just token -> - (model, deleteWeekEntries token model.currentYear model.currentWeek) + ( model, deleteWeekEntries token model.currentYear model.currentWeek ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) WeekEntriesDeleted (Ok _) -> case model.token of Just token -> - ({ model + ( { model | weekEditMode = True , selectedEntries = [] , hasEntriesForCurrentWeek = False - }, fetchMyTimeEntries token) + } + , fetchMyTimeEntries token + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) WeekEntriesDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen" }, Cmd.none) + ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) SwitchTab tab -> let - cmd = case tab of - UsersTab -> - case model.token of - Just token -> - fetchUsers token - Nothing -> - Cmd.none - TimeEntriesTab -> - case model.token of - Just token -> - Cmd.batch - [ fetchAllTimeEntries token - , fetchWeeklyHours token - ] - Nothing -> - Cmd.none - _ -> - Cmd.none + 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 + + _ -> + Cmd.none in - ({ model | activeTab = tab, mobileMenuOpen = False}, cmd) + ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) UpdateNewScheduleDay day -> let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | dayOfWeek = day } + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | dayOfWeek = day } in - ({ model | newSchedule = newSchedule }, Cmd.none) + ( { model | newSchedule = newSchedule }, Cmd.none ) UpdateNewScheduleStart time -> let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | startTime = time } + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | startTime = time } in - ({ model | newSchedule = newSchedule }, Cmd.none) + ( { model | newSchedule = newSchedule }, Cmd.none ) UpdateNewScheduleEnd time -> let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | endTime = time } + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | endTime = time } in - ({ model | newSchedule = newSchedule }, Cmd.none) + ( { model | newSchedule = newSchedule }, Cmd.none ) UpdateNewScheduleType scheduleType -> let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | scheduleType = scheduleType } + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | scheduleType = scheduleType } in - ({ model | newSchedule = newSchedule }, Cmd.none) + ( { model | newSchedule = newSchedule }, Cmd.none ) UpdateNewScheduleTitle title -> let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | title = title } + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | title = title } in - ({ model | newSchedule = newSchedule }, Cmd.none) + ( { 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) + 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) + ( { model | isProcessing = True }, createSchedule token model.newSchedule ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) ScheduleCreated (Ok _) -> case model.token of Just token -> let - emptySchedule = NewSchedule "" "" "" "lesson" "" + emptySchedule = + NewSchedule "" "" "" "lesson" "" in - ({ model + ( { model | newSchedule = emptySchedule , error = Nothing , isProcessing = False - }, fetchSchedules model.token) - + } + , fetchSchedules model.token + ) + Nothing -> - (model, Cmd.none) + ( 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" + 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 + ( { model | error = Just errorMsg , isProcessing = False - }, Cmd.none) + } + , Cmd.none + ) DeleteSchedule scheduleId -> case model.token of Just token -> - (model, deleteSchedule token scheduleId) + ( model, deleteSchedule token scheduleId ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) ScheduleDeleted (Ok _) -> - (model, fetchSchedules model.token) + 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) + ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) UpdateNewUsername username -> let - oldUser = model.newUser - newUser = { oldUser | username = username } + oldUser = + model.newUser + + newUser = + { oldUser | username = username } in - ({ model | newUser = newUser }, Cmd.none) + ( { model | newUser = newUser }, Cmd.none ) UpdateNewPassword password -> let - oldUser = model.newUser - newUser = { oldUser | password = password } + oldUser = + model.newUser + + newUser = + { oldUser | password = password } in - ({ model | newUser = newUser }, Cmd.none) + ( { model | newUser = newUser }, Cmd.none ) UpdateNewUserAdmin isAdmin -> let - oldUser = model.newUser - newUser = { oldUser | isAdmin = isAdmin } + oldUser = + model.newUser + + newUser = + { oldUser | isAdmin = isAdmin } in - ({ model | newUser = newUser }, Cmd.none) + ( { model | newUser = newUser }, Cmd.none ) CreateUser -> case model.token of Just token -> - (model, createUser token model.newUser) + ( model, createUser token model.newUser ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) UserCreated (Ok _) -> let - emptyUser = NewUser "" "" False + emptyUser = + NewUser "" "" False in case model.token of Just token -> - ({ model | newUser = emptyUser }, fetchUsers token) + ( { model | newUser = emptyUser }, fetchUsers token ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) UserCreated (Err _) -> - ({ model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none) + ( { model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none ) DeleteUser userId -> case model.token of Just token -> - (model, deleteUser token userId) + ( model, deleteUser token userId ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) UserDeleted (Ok _) -> case model.token of Just token -> - ({ model + ( { model | pendingDeleteId = Nothing , error = Nothing - }, fetchUsers token) + , editingUserId = Nothing + , resetPasswordUserId = Nothing + } + , fetchUsers token + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) UserDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing}, Cmd.none) + ( { model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing }, Cmd.none ) FetchUsers -> case model.token of Just token -> - (model, fetchUsers token) + ( model, fetchUsers token ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) UsersReceived (Ok users) -> - ({ model | users = users }, Cmd.none) + ( { model | users = users }, Cmd.none ) UsersReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none) + ( { model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none ) FetchMyTimeEntries -> case model.token of Just token -> - (model, fetchMyTimeEntries token) + ( model, fetchMyTimeEntries token ) + Nothing -> - (model, Cmd.none) + ( 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 + hasEntries = + List.any + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + entries in - ({ model + ( { model | timeEntries = entries , hasEntriesForCurrentWeek = hasEntries , weekEditMode = False - }, Cmd.none) + } + , Cmd.none + ) MyTimeEntriesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none) + ( { model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none ) FetchAllTimeEntries -> case model.token of Just token -> - (model, fetchAllTimeEntries token) + ( model, fetchAllTimeEntries token ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) AllTimeEntriesReceived (Ok entries) -> - ({ model | timeEntries = entries }, Cmd.none) + ( { model | timeEntries = entries }, Cmd.none ) AllTimeEntriesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none) + ( { model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none ) FetchWeeklyHours -> case model.token of Just token -> - (model, fetchWeeklyHours token) + ( model, fetchWeeklyHours token ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) WeeklyHoursReceived (Ok hours) -> - ({ model | weeklyHours = hours }, Cmd.none) + ( { model | weeklyHours = hours }, Cmd.none ) WeeklyHoursReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none) - FetchMyWeeklySummary -> + ( { model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none ) + + FetchYearlyHoursSummary -> case model.token of Just token -> - (model, fetchMyWeeklySummary token model.currentYear model.currentWeek) + ( model, fetchYearlyHoursSummary token ) + Nothing -> - (model, Cmd.none) + ( 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) + ( { model | userWeeklySummary = Just summary }, Cmd.none ) MyWeeklySummaryReceived (Err _) -> - ({ model | userWeeklySummary = Nothing }, Cmd.none) + ( { model | userWeeklySummary = Nothing }, Cmd.none ) EditTimeEntry entryId -> case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of Just entry -> - ({ model + ( { model | editingTimeEntryId = Just entryId - , editingTimeEntry = + , editingTimeEntry = { entryId = entryId , date = entry.date , startTime = entry.startTime , endTime = entry.endTime , entryType = entry.entryType } - }, Cmd.none) + } + , Cmd.none + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) CancelEditTimeEntry -> - ({ model + ( { model | editingTimeEntryId = Nothing , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" - }, Cmd.none) + } + , Cmd.none + ) UpdateEditTimeEntryDate date -> let - old = model.editingTimeEntry - new = { old | date = date } + old = + model.editingTimeEntry + + new = + { old | date = date } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) UpdateEditTimeEntryStartTime time -> let - old = model.editingTimeEntry - new = { old | startTime = time } + old = + model.editingTimeEntry + + new = + { old | startTime = time } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) UpdateEditTimeEntryEndTime time -> let - old = model.editingTimeEntry - new = { old | endTime = time } + old = + model.editingTimeEntry + + new = + { old | endTime = time } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) UpdateEditTimeEntryType entryType -> let - old = model.editingTimeEntry - new = { old | entryType = entryType } + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) SaveEditTimeEntry -> case model.token of Just token -> - (model, updateTimeEntry token model.editingTimeEntry) + ( model, updateTimeEntry token model.editingTimeEntry ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) TimeEntryDeleted (Ok _) -> case model.token of Just token -> - ({ model + ( { model | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" , pendingDeleteId = Nothing , error = Nothing - }, fetchAllTimeEntries token) + } + , Cmd.batch + [ fetchAllTimeEntries token + , fetchWeeklyHours token + , fetchYearlyHoursSummary token + ] + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) TimeEntryDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing}, Cmd.none) + ( { 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 + ( { model | editingUserId = Just userId - , editingUserWorkHours = String.fromFloat user.weeklyWorkHours - }, Cmd.none) + , editingUserWorkHours = String.fromFloat user.yearlyWorkHours + } + , Cmd.none + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) CancelEditUserWorkHours -> - ({ model + ( { model | editingUserId = Nothing , editingUserWorkHours = "" - }, Cmd.none) + } + , Cmd.none + ) UpdateEditUserWorkHours hours -> - ({ model | editingUserWorkHours = hours }, Cmd.none) + ( { model | editingUserWorkHours = hours }, Cmd.none ) ResetUserPassword userId -> - ({ model + ( { model | resetPasswordUserId = Just userId , resetPasswordNew = "" - }, Cmd.none) + } + , Cmd.none + ) CancelResetPassword -> - ({ model + ( { model | resetPasswordUserId = Nothing , resetPasswordNew = "" - }, Cmd.none) + } + , Cmd.none + ) UpdateResetPasswordNew password -> - ({ model | resetPasswordNew = password }, Cmd.none) + ( { model | resetPasswordNew = password }, Cmd.none ) SaveResetPassword -> case model.resetPasswordUserId of Just userId -> case model.token of Just token -> - (model, resetUserPassword token userId model.resetPasswordNew) + ( model, resetUserPassword token userId model.resetPasswordNew ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) ResetPasswordSaved (Ok _) -> - ({ model + ( { model | resetPasswordUserId = Nothing , resetPasswordNew = "" , error = Just "Passwort erfolgreich zurückgesetzt" - }, case model.token of - Just token -> - fetchUsers token - Nothing -> - Cmd.none - ) + } + , case model.token of + Just token -> + fetchUsers token + + Nothing -> + Cmd.none + ) ResetPasswordSaved (Err _) -> - ({ model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none) + ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + StartEditingTimeEntry entryId entry -> - ({ model + ( { model | editingTimeEntryId = Just entryId , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType - }, Cmd.none) + } + , Cmd.none + ) CancelEditingTimeEntry -> - ({ model + ( { model | editingTimeEntryId = Nothing , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - }, Cmd.none) + } + , Cmd.none + ) UpdateEditingTimeEntryDate date -> let - old = model.editingTimeEntry - new = { old | date = date } + old = + model.editingTimeEntry + + new = + { old | date = date } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) UpdateEditingTimeEntryStartTime time -> let - old = model.editingTimeEntry - new = { old | startTime = time } + old = + model.editingTimeEntry + + new = + { old | startTime = time } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) UpdateEditingTimeEntryEndTime time -> let - old = model.editingTimeEntry - new = { old | endTime = time } + old = + model.editingTimeEntry + + new = + { old | endTime = time } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) UpdateEditingTimeEntryType entryType -> let - old = model.editingTimeEntry - new = { old | entryType = entryType } + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } in - ({ model | editingTimeEntry = new }, Cmd.none) + ( { model | editingTimeEntry = new }, Cmd.none ) SaveEditingTimeEntry -> - case (model.token, model.editingTimeEntryId) of - (Just token, Just entryId) -> - (model, updateTimeEntry token model.editingTimeEntry) + case ( model.token, model.editingTimeEntryId ) of + ( Just token, Just entryId ) -> + ( model, updateTimeEntry token model.editingTimeEntry ) + _ -> - (model, Cmd.none) + ( model, Cmd.none ) TimeEntrySaved (Ok _) -> case model.token of Just token -> - ({ model + ( { model | editingTimeEntryId = Nothing , pendingDeleteId = Nothing , error = Nothing - }, fetchAllTimeEntries token) + } + , fetchAllTimeEntries token + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) TimeEntrySaved (Err _) -> - ({ model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none) + ( { model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none ) ConfirmDeleteTimeEntry entryId -> - ({ model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?") + ( { 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?") + ( { 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) -> + case ( model.token, model.pendingDeleteId ) of + ( Just token, Just id ) -> let - isTimeEntry = List.any (\e -> e.id == id) model.timeEntries + isTimeEntry = + List.any (\e -> e.id == id) model.timeEntries in if isTimeEntry then - (model, deleteTimeEntry token id) + ( model, deleteTimeEntry token id ) + else - (model, deleteUser token id) + ( model, deleteUser token id ) + _ -> - (model, Cmd.none) + ( model, Cmd.none ) + else - ({ model | pendingDeleteId = Nothing }, Cmd.none) + ( { model | pendingDeleteId = Nothing }, Cmd.none ) SelectUserForManagement userId -> - ({ model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none) + ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) UpdateUserWorkHours input -> - ({ model | userWorkHoursInput = input }, Cmd.none) + ( { 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)) + 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) + ( { model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none ) UserWorkHoursSaved (Ok _) -> case model.token of Just token -> - ({ model + ( { model | editingUserWorkHours = "" , editingUserId = Nothing , error = Nothing - }, fetchUsers token) + } + , fetchUsers token + ) + Nothing -> - (model, Cmd.none) + ( model, Cmd.none ) UserWorkHoursSaved (Err _) -> - ({ model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none) + ( { model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none ) UpdateUserPassword input -> - ({ model | userPasswordInput = input }, Cmd.none) + ( { model | userPasswordInput = input }, Cmd.none ) SaveUserPassword -> - case (model.token, model.selectedUserId) of - (Just token, Just userId) -> + case ( model.token, model.selectedUserId ) of + ( Just token, Just userId ) -> if String.length model.userPasswordInput > 0 then - (model, resetUserPassword token userId model.userPasswordInput) + ( model, resetUserPassword token userId model.userPasswordInput ) + else - ({ model | error = Just "Passwort erforderlich" }, Cmd.none) - + ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + _ -> - ({ model | error = Just "Passwort erforderlich" }, Cmd.none) + ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) UserPasswordSaved (Ok _) -> - ({ model + ( { model | userPasswordInput = "" , selectedUserId = Nothing , error = Nothing - }, Cmd.none) + } + , Cmd.none + ) UserPasswordSaved (Err _) -> - ({ model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none) + ( { 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 ) + + UpdateManualEntryStartTime time -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | startTime = time } }, Cmd.none ) + + UpdateManualEntryEndTime time -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | endTime = time } }, 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 "" "" "" "lesson" + , 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 ) -- SUBSCRIPTIONS + subscriptions : Model -> Sub Msg subscriptions model = confirmDeleteResponse DeleteConfirmed + -- HELPER FUNCTIONS -getISOWeekFromPosix : Time.Posix -> (Int, Int) + +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 + 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) + ( 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 + 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 + 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 + 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 + 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 + 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) = + 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) + 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) + 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 : Int -> Int -> Int -> Int -> ( Int, Int, Int ) addDaysToDate startYear startMonth startDay daysToAdd = let - daysInMonth m y = + 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 - + 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) + ( y, m, d ) + else if remaining > 0 then let - daysInCurrentMonth = daysInMonth m y - daysLeftInMonth = daysInCurrentMonth - d + daysInCurrentMonth = + daysInMonth m y + + daysLeftInMonth = + daysInCurrentMonth - d in if remaining <= daysLeftInMonth then - (y, m, d + remaining) + ( 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 - 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) + 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 : Int -> Int -> ( Int, Int ) previousWeek year week = if week == 1 then - (year - 1, 52) - else - (year, week - 1) + ( year - 1, 52 ) -nextWeek : Int -> Int -> (Int, Int) + else + ( year, week - 1 ) + + +nextWeek : Int -> Int -> ( Int, Int ) nextWeek year week = if week >= 52 then - (year + 1, 1) + ( year + 1, 1 ) + else - (year, week + 1) + ( year, week + 1 ) + getWeekDateRange : Int -> Int -> String getWeekDateRange year week = let - mondayDate = getDateForWeekDay year week 0 - fridayDate = getDateForWeekDay year week 4 + mondayDate = + getDateForWeekDay year week 0 + + fridayDate = + getDateForWeekDay year week 4 in mondayDate ++ " bis " ++ fridayDate -getYearWeekFromDate : String -> (Int, Int) + +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 + 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) + ( 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) + [ h, m ] -> + (String.toFloat h |> Maybe.withDefault 0) + + ((String.toFloat m |> Maybe.withDefault 0) / 60) + _ -> 0 - - start = parseTime startTime - end = parseTime endTime + + start = + parseTime startTime + + end = + parseTime endTime in if end > start then end - start + else 0 + -- VIEW + view : Model -> Html Msg view model = div [ class "container" ] @@ -1313,6 +1831,7 @@ view model = viewAdminDashboard model ] + viewLogin : Model -> Html Msg viewLogin model = section [ class "section" ] @@ -1324,38 +1843,42 @@ viewLogin model = , 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 + [ input [ class "input" , type_ "text" , placeholder "Benutzername" , value model.username , onInput UpdateUsername - ] [] + ] + [] ] ] , div [ class "field" ] [ label [ class "label" ] [ text "Passwort" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "password" , placeholder "Passwort" , value model.password , onInput UpdatePassword - ] [] + ] + [] ] ] , div [ class "field" ] [ div [ class "control" ] - [ button + [ button [ class "button is-primary is-fullwidth" , onClick Login - ] [ text "Anmelden" ] + ] + [ text "Anmelden" ] ] ] ] @@ -1364,19 +1887,34 @@ viewLogin model = ] ] + viewUserDashboard : Model -> Html Msg viewUserDashboard model = - div [] + 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 "")) + , 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") + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) , onClick ToggleMobileMenu ] [ span [ attribute "aria-hidden" "true" ] [] @@ -1384,16 +1922,24 @@ viewUserDashboard model = , span [ attribute "aria-hidden" "true" ] [] ] ] - , div + , div [ id "navbarUser" - , class ("navbar-menu" ++ (if model.mobileMenuOpen then " is-active" else "")) -- NEU! + , 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 ] + [ button [ class "button is-light", onClick Logout ] [ span [ class "icon" ] [ i [ class "fas fa-sign-out-alt" ] [] ] , span [] [ text "Abmelden" ] @@ -1406,7 +1952,6 @@ viewUserDashboard model = [ 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" ] @@ -1419,15 +1964,17 @@ viewUserDashboard model = ] , div [ class "level-right" ] [ div [ class "level-item" ] - [ button + [ button [ class "button is-warning" , onClick EnableEditMode , disabled model.isProcessing - ] [ text "Bearbeiten" ] + ] + [ text "Bearbeiten" ] ] ] ] ] + else if model.weekEditMode then div [ class "notification is-warning" ] [ div [ class "level" ] @@ -1440,66 +1987,91 @@ viewUserDashboard model = ] , div [ class "level-right" ] [ div [ class "level-item" ] - [ button + [ button [ class "button is-danger is-small mr-2" , onClick DeleteWeekEntries , disabled model.isProcessing - ] [ text "Einträge löschen" ] - , button + ] + [ text "Einträge löschen" ] + , button [ class "button is-light is-small" , onClick DisableEditMode - ] [ text "Abbrechen" ] + ] + [ 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 + [ 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") + , text + (if model.weekEditMode then + "Änderungen speichern" + + else + "Speichern" + ) ] ] ] + else text "" - , h3 [ class "subtitle mt-6" ] [ text "Wochenzusammenfassung" ] - , viewUserWeeklySummary model - + , 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 [] + 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 "")) + , 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") + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) , onClick ToggleMobileMenu ] [ span [ attribute "aria-hidden" "true" ] [] @@ -1507,16 +2079,24 @@ viewAdminDashboard model = , span [ attribute "aria-hidden" "true" ] [] ] ] - , div + , div [ id "navbarAdmin" - , class ("navbar-menu" ++ (if model.mobileMenuOpen then " is-active" else "")) + , 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 ] + [ button [ class "button is-light", onClick Logout ] [ span [ class "icon" ] [ i [ class "fas fa-sign-out-alt" ] [] ] , span [] [ text "Abmelden" ] @@ -1529,74 +2109,112 @@ viewAdminDashboard model = [ div [ class "container" ] [ div [ class "tabs is-boxed" ] [ ul [] - [ li [ classList [("is-active", model.activeTab == ScheduleTab)] ] + [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] - , li [ classList [("is-active", model.activeTab == UsersTab)] ] + , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] - , li [ classList [("is-active", model.activeTab == TimeEntriesTab)] ] + , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] ] ] , case model.activeTab of ScheduleTab -> viewScheduleTab model + UsersTab -> viewUsersTab model + TimeEntriesTab -> viewTimeEntriesTab model ] ] ] + 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 = + 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" + + 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 + div [ class boxClass - , onClick (if isClickable then ToggleScheduleSelection schedule.id dayOfWeek else FetchSchedules) + , 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") + , style "border" + (if isClickable && not isSelected then + "2px solid transparent" + + else + "2px solid currentColor" + ) ] - [ p [ class "has-text-weight-bold is-size-7" ] + [ p [ class "has-text-weight-bold is-size-7" ] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [ class "is-size-7" ] + , 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 ) - ) + 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 "is-hidden-mobile" ] [ div [ class "table-container" ] [ table [ class "table is-bordered is-fullwidth" ] [ thead [] @@ -1613,37 +2231,44 @@ viewScheduleGridWithWeek model = (List.map2 (viewDayMobile model) days groupedSchedules) ] -viewDayMobile : Model -> String -> (Int, List Schedule) -> Html Msg -viewDayMobile model dayName (dayOfWeek, schedules) = + +viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg +viewDayMobile model dayName ( dayOfWeek, schedules ) = let - dateForDay = + dateForDay = case model.weekDates of Just wd -> - wd.dates - |> List.filter (\(day, _) -> day == String.fromInt dayOfWeek) + 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" ] + [ 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 = + 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 @@ -1656,28 +2281,101 @@ viewUserWeeklySummary model = ] , div [ class "column" ] [ p [ class "heading" ] [ text "Verbleibend" ] - , p [ class "title is-4", classList [("has-text-success", summary.remainingHours <= 0)] ] + , 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 + , 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 1800 + + 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 [] @@ -1686,6 +2384,7 @@ viewScheduleTab model = , viewScheduleList model ] + viewUsersTab : Model -> Html Msg viewUsersTab model = div [] @@ -1694,21 +2393,175 @@ viewUsersTab model = , viewUserList model ] + viewTimeEntriesTab : Model -> Html Msg viewTimeEntriesTab model = div [] - [ viewWeekNavigation model - , h2 [ class "title" ] [ text "Wochenstunden Übersicht" ] - , viewWeeklyHoursSummary model + [ 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" ] + [ 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 "Neuer Zeiteintrag" ] + , div [ class "columns" ] + [ div [ class "column" ] + [ 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 [] [ text "-- Wählen --" ] + :: List.map (\user -> option [ value (String.fromInt user.id) ] [ text user.username ]) + (List.filter (\u -> not u.isAdmin) model.users) + ) + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.adminManualEntryForm.date + , onInput UpdateManualEntryDate + ] + [] + ] + ] + ] + ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.adminManualEntryForm.startTime + , onInput UpdateManualEntryStartTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.adminManualEntryForm.endTime + , onInput UpdateManualEntryEndTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateManualEntryType, value model.adminManualEntryForm.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-info" + , onClick SaveAdminTimeEntry + , disabled + (case model.adminManualEntryForm.selectedUserId of + Just _ -> + model.isProcessing + + Nothing -> + True + ) + ] + [ text "Eintrag erstellen" ] + ] + ] + ] + + viewTimeEntriesEditForm : Model -> Html Msg viewTimeEntriesEditForm model = div [ class "box has-background-warning-light" ] @@ -1718,12 +2571,13 @@ viewTimeEntriesEditForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Datum" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "date" , value model.editingTimeEntry.date , onInput UpdateEditTimeEntryDate - ] [] + ] + [] ] ] ] @@ -1731,12 +2585,13 @@ viewTimeEntriesEditForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Startzeit" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "time" , value model.editingTimeEntry.startTime , onInput UpdateEditTimeEntryStartTime - ] [] + ] + [] ] ] ] @@ -1744,12 +2599,13 @@ viewTimeEntriesEditForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Endzeit" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "time" , value model.editingTimeEntry.endTime , onInput UpdateEditTimeEntryEndTime - ] [] + ] + [] ] ] ] @@ -1769,36 +2625,30 @@ viewTimeEntriesEditForm model = ] , div [ class "field is-grouped mt-4" ] [ div [ class "control" ] - [ button + [ button [ class "button is-success" , onClick SaveEditTimeEntry - ] [ text "Speichern" ] + ] + [ text "Speichern" ] ] , div [ class "control" ] - [ button + [ button [ class "button is-light" , onClick CancelEditTimeEntry - ] [ text "Abbrechen" ] + ] + [ text "Abbrechen" ] ] ] , viewTimeEntriesListWithEdit model ] + viewTimeEntriesListWithEdit : Model -> Html Msg viewTimeEntriesListWithEdit model = - let - filteredEntries = List.filter - (\e -> - let - (entryYear, entryWeek) = getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - in div [ class "box" ] - [ if List.isEmpty filteredEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + [ 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 [] @@ -1812,44 +2662,51 @@ viewTimeEntriesListWithEdit model = ] ] , tbody [] - (List.map (viewTimeEntryRowWithEdit model) filteredEntries) + (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 + hours = + calculateHours entry.startTime entry.endTime + + isEditing = + model.editingTimeEntryId == Just entry.id in if isEditing then tr [] [ td [] [ text entry.username ] - , td [] - [ input + , td [] + [ input [ class "input is-small" , type_ "date" , value model.editingTimeEntry.date , onInput UpdateEditTimeEntryDate - ] [] + ] + [] ] , td [] [ div [ class "field is-grouped" ] [ div [ class "control" ] - [ input + [ input [ class "input is-small" , type_ "time" , value model.editingTimeEntry.startTime , onInput UpdateEditTimeEntryStartTime - ] [] + ] + [] ] , div [ class "control" ] - [ input + [ input [ class "input is-small" , type_ "time" , value model.editingTimeEntry.endTime , onInput UpdateEditTimeEntryEndTime - ] [] + ] + [] ] ] ] @@ -1867,6 +2724,7 @@ viewTimeEntryRowWithEdit model entry = , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] ] ] + else tr [] [ td [] [ text entry.username ] @@ -1875,29 +2733,36 @@ viewTimeEntryRowWithEdit model entry = , td [] [ text entry.entryType ] , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] , td [ class "has-text-centered" ] - [ button + [ button [ class "button is-small is-info mr-2" , onClick (EditTimeEntry entry.id) - ] [ text "Bearbeiten" ] - , button + ] + [ text "Bearbeiten" ] + , button [ class "button is-small is-danger" , onClick (ConfirmDeleteTimeEntry entry.id) - ] [ text "Löschen" ] + ] + [ text "Löschen" ] ] ] + + viewWeekNavigation : Model -> Html Msg viewWeekNavigation model = let - dateRange = + dateRange = case model.weekDates of - Just wd -> wd.range - Nothing -> "Laden..." + Just wd -> + wd.range + + Nothing -> + "Laden..." in div [ class "box" ] [ nav [ class "level" ] [ div [ class "level-left" ] [ div [ class "level-item" ] - [ button + [ button [ class "button is-primary" , onClick PreviousWeek ] @@ -1907,15 +2772,15 @@ viewWeekNavigation model = , div [ class "level-item has-text-centered" ] [ div [] [ p [ class "heading" ] [ text "Kalenderwoche" ] - , p [ class "title" ] + , p [ class "title" ] [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] - , p [ class "subtitle is-6" ] + , p [ class "subtitle is-6" ] [ text dateRange ] ] ] , div [ class "level-right" ] [ div [ class "level-item" ] - [ button + [ button [ class "button is-primary" , onClick NextWeek ] @@ -1925,22 +2790,24 @@ viewWeekNavigation model = ] ] -viewDayColumnWithWeek : Model -> (Int, List Schedule) -> Html Msg -viewDayColumnWithWeek model (dayOfWeek, schedules) = + +viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg +viewDayColumnWithWeek model ( dayOfWeek, schedules ) = let - dateForDay = + dateForDay = case model.weekDates of Just wd -> - wd.dates - |> List.filter (\(day, _) -> day == String.fromInt dayOfWeek) + 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" ] + [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] [ text dateForDay ] , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) ] @@ -1955,7 +2822,7 @@ viewScheduleForm model = [ label [ class "label" ] [ text "Wochentag" ] , div [ class "control" ] [ div [ class "select is-fullwidth" ] - [ select + [ select [ onInput UpdateNewScheduleDay , disabled model.isProcessing , value model.newSchedule.dayOfWeek @@ -1975,13 +2842,14 @@ viewScheduleForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Startzeit" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "time" , value model.newSchedule.startTime , onInput UpdateNewScheduleStart , disabled model.isProcessing - ] [] + ] + [] ] ] ] @@ -1989,13 +2857,14 @@ viewScheduleForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Endzeit" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "time" , value model.newSchedule.endTime , onInput UpdateNewScheduleEnd , disabled model.isProcessing - ] [] + ] + [] ] ] ] @@ -2006,7 +2875,7 @@ viewScheduleForm model = [ label [ class "label" ] [ text "Typ" ] , div [ class "control" ] [ div [ class "select is-fullwidth" ] - [ select + [ select [ onInput UpdateNewScheduleType , value model.newSchedule.scheduleType , disabled model.isProcessing @@ -2022,27 +2891,29 @@ viewScheduleForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Titel" ] , div [ class "control" ] - [ input + [ 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 + [ 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" @@ -2051,10 +2922,12 @@ viewScheduleForm model = ] , 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" ] @@ -2074,32 +2947,52 @@ viewScheduleList model = ] ] + 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" + 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 + , td [] + [ button [ class "button is-small is-danger" , onClick (DeleteSchedule schedule.id) - ] [ text "Löschen" ] + ] + [ text "Löschen" ] ] ] + viewUserForm : Model -> Html Msg viewUserForm model = div [ class "box" ] @@ -2108,13 +3001,14 @@ viewUserForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Benutzername" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "text" , placeholder "Benutzername" , value model.newUser.username , onInput UpdateNewUsername - ] [] + ] + [] ] ] ] @@ -2122,13 +3016,14 @@ viewUserForm model = [ div [ class "field" ] [ label [ class "label" ] [ text "Passwort" ] , div [ class "control" ] - [ input + [ input [ class "input" , type_ "password" , placeholder "Passwort" , value model.newUser.password , onInput UpdateNewPassword - ] [] + ] + [] ] ] ] @@ -2137,11 +3032,12 @@ viewUserForm model = [ label [ class "label" ] [ text "Admin" ] , div [ class "control" ] [ label [ class "checkbox" ] - [ input + [ input [ type_ "checkbox" , checked model.newUser.isAdmin , onCheck UpdateNewUserAdmin - ] [] + ] + [] , text " Admin-Rechte" ] ] @@ -2155,12 +3051,14 @@ viewUserForm model = ] ] + 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 [] @@ -2168,7 +3066,7 @@ viewUserList model = [ th [] [ text "ID" ] , th [] [ text "Benutzername" ] , th [] [ text "Rolle" ] - , th [ class "has-text-right" ] [ text "Arbeitszeit/Woche" ] + , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] , th [ class "has-text-centered" ] [ text "Aktionen" ] ] ] @@ -2177,100 +3075,147 @@ viewUserList model = ] ] + 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 + [ 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 + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input [ class "input is-small" , type_ "password" , placeholder "Neues Passwort" , value model.resetPasswordNew , onInput UpdateResetPasswordNew - ] [] + ] + [] ] , td [ class "has-text-centered" ] [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] ] ] + else tr [] [ td [] [ text (String.fromInt user.id) ] , td [] [ text user.username ] - , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] - , td [ class "has-text-right" ] [ text (String.fromFloat user.weeklyWorkHours ++ " Std.") ] + , td [] + [ 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 + [ button [ class "button is-small is-info mr-2" , onClick (EditUserWorkHours user.id) - ] [ text "Arbeitszeit" ] - , button + ] + [ text "Arbeitszeit" ] + , button [ class "button is-small is-warning mr-2" , onClick (ResetUserPassword user.id) - ] [ text "PW Reset" ] - , button + ] + [ text "PW Reset" ] + , button [ class "button is-small is-danger" , onClick (DeleteUser user.id) - ] [ text "Löschen" ] + ] + [ 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 [] + , 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 + button [ class "button is-small is-danger" , onClick (DeleteUser user.id) - ] [ text "Löschen" ] + ] + [ text "Löschen" ] ] ] + viewWeeklyHoursSummary : Model -> Html Msg viewWeeklyHoursSummary model = let - filteredHours = List.filter - (\h -> h.week == model.currentWeek && h.year == model.currentYear) - model.weeklyHours + 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 [] @@ -2287,9 +3232,9 @@ viewWeeklyHoursSummary model = , tfoot [] [ tr [ class "has-background-light" ] [ th [] [ text "Gesamt" ] - , th [ class "has-text-right has-text-weight-bold" ] + , 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" ] + , th [ class "has-text-right has-text-weight-bold" ] [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] , th [] [ text "" ] , th [] [ text "" ] @@ -2298,15 +3243,20 @@ viewWeeklyHoursSummary model = ] ] + viewWeeklyHoursRow : WeeklyHours -> Html Msg viewWeeklyHoursRow hours = let - progressPercent = Basics.min 100 (hours.totalHours / hours.targetHours * 100) - progressColor = + 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 @@ -2316,29 +3266,34 @@ viewWeeklyHoursRow hours = , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] , td [] - [ progress + [ 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 + 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 [] @@ -2355,13 +3310,15 @@ viewTimeEntriesList model = ] ] + viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg viewTimeEntryRowWithActions model entry = let - hours = - if entry.entryType == "lesson" then - 1.0 - else + hours = + if entry.entryType == "lesson" then + 1.0 + + else calculateHours entry.startTime entry.endTime in tr [] @@ -2370,40 +3327,48 @@ viewTimeEntryRowWithActions model entry = , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] , td [] [ text entry.entryType ] , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [] + , td [] [ div [ class "buttons are-small" ] - [ button + [ button [ class "button is-info is-small" , onClick (StartEditingTimeEntry entry.id entry) - ] [ text "Bearbeiten" ] - , button + ] + [ text "Bearbeiten" ] + , button [ class "button is-danger is-small" , onClick (ConfirmDeleteTimeEntry entry.id) - ] [ text "Löschen" ] + ] + [ 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) - ] + , 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 @@ -2411,6 +3376,7 @@ loginDecoder = (field "username" string) (field "is_admin" bool) + fetchSchedules : Maybe String -> Cmd Msg fetchSchedules maybeToken = case maybeToken of @@ -2424,9 +3390,11 @@ fetchSchedules maybeToken = , timeout = Nothing , tracker = Nothing } + Nothing -> Cmd.none + scheduleDecoder : Decoder Schedule scheduleDecoder = Decode.map6 Schedule @@ -2437,6 +3405,7 @@ scheduleDecoder = (field "type" string) (field "title" string) + fetchMyTimeEntries : String -> Cmd Msg fetchMyTimeEntries token = Http.request @@ -2449,51 +3418,57 @@ fetchMyTimeEntries token = , 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.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) - ] + 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 + + 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) ] + , 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 @@ -2506,6 +3481,7 @@ deleteWeekEntries token year week = , tracker = Nothing } + createSchedule : String -> NewSchedule -> Cmd Msg createSchedule token schedule = case String.toInt schedule.dayOfWeek of @@ -2514,21 +3490,24 @@ createSchedule token schedule = { 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) - ] + , 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 @@ -2541,23 +3520,26 @@ deleteSchedule token scheduleId = , 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) - ] + , 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 @@ -2570,6 +3552,7 @@ deleteUser token userId = , tracker = Nothing } + fetchUsers : String -> Cmd Msg fetchUsers token = Http.request @@ -2582,13 +3565,15 @@ fetchUsers token = , tracker = Nothing } + userDecoder : Decoder User userDecoder = Decode.map4 User (field "id" int) (field "username" string) (field "is_admin" bool) - (field "weekly_hours" float) -- NEU + (field "yearly_hours" float) + fetchAllTimeEntries : String -> Cmd Msg fetchAllTimeEntries token = @@ -2602,6 +3587,7 @@ fetchAllTimeEntries token = , tracker = Nothing } + timeEntryDecoder : Decoder TimeEntry timeEntryDecoder = Decode.map8 TimeEntry @@ -2614,6 +3600,7 @@ timeEntryDecoder = (field "start_time" string) (field "end_time" string) + fetchWeeklyHours : String -> Cmd Msg fetchWeeklyHours token = Http.request @@ -2626,6 +3613,7 @@ fetchWeeklyHours token = , tracker = Nothing } + weeklyHoursDecoder : Decoder WeeklyHours weeklyHoursDecoder = Decode.map7 WeeklyHours @@ -2637,6 +3625,60 @@ weeklyHoursDecoder = (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 ) + , ( "start_time", Encode.string entry.startTime ) + , ( "end_time", Encode.string entry.endTime ) + , ( "type", Encode.string entry.entryType ) + ] + , expect = Http.expectWhatever AdminTimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + fetchWeekDates : String -> Int -> Int -> Cmd Msg fetchWeekDates token year week = Http.request @@ -2649,6 +3691,7 @@ fetchWeekDates token year week = , tracker = Nothing } + weekDatesDecoder : Decoder WeekDates weekDatesDecoder = Decode.map4 WeekDates @@ -2657,6 +3700,7 @@ weekDatesDecoder = (field "dates" (Decode.dict string) |> Decode.map Dict.toList) (field "range" string) + checkWeekHasEntries : String -> Int -> Int -> Cmd Msg checkWeekHasEntries token year week = Http.request @@ -2669,28 +3713,6 @@ checkWeekHasEntries token year week = , tracker = Nothing } -fetchMyWeeklySummary : String -> Int -> Int -> Cmd Msg -fetchMyWeeklySummary token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-weekly-summary?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson MyWeeklySummaryReceived weeklySummaryDecoder - , timeout = Nothing - , tracker = Nothing - } - -weeklySummaryDecoder : Decoder WeeklySummary -weeklySummaryDecoder = - Decode.map7 WeeklySummary - (field "user_id" int) - (field "username" string) - (field "year" int) - (field "week" int) - (field "total_hours" float) - (field "expected_hours" float) - (field "remaining_hours" float) updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg updateTimeEntry token entry = @@ -2698,18 +3720,20 @@ updateTimeEntry token entry = { 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) - ] + , 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 @@ -2722,6 +3746,7 @@ deleteTimeEntry token entryId = , tracker = Nothing } + updateUserWorkHours : String -> Int -> String -> Cmd Msg updateUserWorkHours token userId hours = case String.toFloat hours of @@ -2730,27 +3755,43 @@ updateUserWorkHours token userId hours = { method = "PUT" , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] , url = "/api/admin/users/" ++ String.fromInt userId - , body = Http.jsonBody <| - Encode.object - [ ("weekly_hours", Encode.float workHours) ] + , 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) ] + , 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 + }