From 9c259567112f7226758aaf303f0244d62a413267 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 5 Nov 2025 17:09:37 +0100 Subject: [PATCH 01/13] fix: Add Mobile View and fix error while freeze on entering new schedule --- backend/database.go | 186 ++----------- backend/load-env.sh | 22 ++ backend/middleware.go | 87 +++++- backend/static/index.html | 145 ++++++++-- frontend/public/index.html | 160 +++++++++-- frontend/src/Main.elm | 538 +++++++++++++++++++++++-------------- 6 files changed, 746 insertions(+), 392 deletions(-) create mode 100755 backend/load-env.sh diff --git a/backend/database.go b/backend/database.go index 32ffd22..4da250c 100644 --- a/backend/database.go +++ b/backend/database.go @@ -24,6 +24,7 @@ func InitDB(filepath string) *sql.DB { } createTables(db) + createIndexes(db) return db } @@ -34,7 +35,8 @@ func createTables(db *sql.DB) { username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, is_admin BOOLEAN NOT NULL DEFAULT 0, - weekly_hours REAL NOT NULL DEFAULT 40.0 + weekly_hours REAL NOT NULL DEFAULT 40.0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -42,7 +44,8 @@ func createTables(db *sql.DB) { start_time TEXT NOT NULL, end_time TEXT NOT NULL, type TEXT NOT NULL, - title TEXT NOT NULL + title TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS time_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -56,6 +59,13 @@ func createTables(db *sql.DB) { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (schedule_id) REFERENCES schedules(id) )`, + `CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + action TEXT NOT NULL, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, } for _, query := range queries { @@ -64,6 +74,7 @@ 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) @@ -75,52 +86,21 @@ func createTables(db *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 -// )`, -// `CREATE TABLE IF NOT EXISTS schedules ( -// id INTEGER PRIMARY KEY AUTOINCREMENT, -// day_of_week INTEGER NOT NULL, -// start_time TEXT NOT NULL, -// end_time TEXT NOT NULL, -// type TEXT NOT NULL, -// title TEXT NOT NULL -// )`, -// `CREATE TABLE IF NOT EXISTS time_entries ( -// id INTEGER PRIMARY KEY AUTOINCREMENT, -// user_id INTEGER NOT NULL, -// schedule_id INTEGER NOT NULL, -// date TEXT NOT NULL, -// type TEXT NOT NULL, -// start_time TEXT NOT NULL, -// end_time TEXT NOT NULL, -// created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -// FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -// FOREIGN KEY (schedule_id) REFERENCES schedules(id) -// )`, -// } +func createIndexes(db *sql.DB) { + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`, + `CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`, + } -// for _, query := range queries { -// if _, err := db.Exec(query); err != nil { -// log.Fatal(err) -// } -// } - -// hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) -// _, err := db.Exec(` -// INSERT OR IGNORE INTO users (id, username, password, is_admin) -// VALUES (?, ?, ?, ?)`, -// 1, "admin", string(hash), true, -// ) -// if err != nil { -// log.Fatal(err) -// } -// } + for _, idx := range indexes { + if _, err := db.Exec(idx); err != nil { + log.Printf("Warning: Failed to create index: %v", err) + } + } +} func GetUserByUsername(db *sql.DB, username string) (*User, error) { user := &User{} @@ -149,7 +129,7 @@ func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, weekl } func GetAllUsers(db *sql.DB) ([]User, error) { - rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users") + rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users ORDER BY username") if err != nil { return nil, err } @@ -166,40 +146,6 @@ func GetAllUsers(db *sql.DB) ([]User, error) { return users, nil } -// func GetUserByUsername(db *sql.DB, username string) (*User, error) { -// user := &User{} -// err := db.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username). -// Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin) -// if err != nil { -// return nil, err -// } -// return user, nil -// } - -// func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool) error { -// _, err := db.Exec("INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", -// username, hashedPassword, isAdmin) -// return err -// } - -// func GetAllUsers(db *sql.DB) ([]User, error) { -// rows, err := db.Query("SELECT id, username, is_admin FROM users") -// if err != nil { -// return nil, err -// } -// defer rows.Close() - -// var users []User -// for rows.Next() { -// var u User -// if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin); err != nil { -// continue -// } -// users = append(users, u) -// } -// return users, nil -// } - func UpdateUser(db *sql.DB, userID int, weeklyHours float64) error { _, err := db.Exec("UPDATE users SET weekly_hours = ? WHERE id = ?", weeklyHours, userID) @@ -395,74 +341,6 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { return result, nil } -// func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { -// rows, err := db.Query(` -// SELECT -// te.user_id, -// u.username, -// te.date, -// te.start_time, -// te.end_time -// FROM time_entries te -// JOIN users u ON te.user_id = u.id -// ORDER BY te.date DESC -// `) -// if err != nil { -// return nil, err -// } -// defer rows.Close() - -// hoursMap := make(map[string]*WeeklyHours) - -// for rows.Next() { -// var userID int -// var username, dateStr, startTime, endTime string - -// if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime); err != nil { -// continue -// } - -// t, err := time.Parse("2006-01-02", dateStr) -// if err != nil { -// continue -// } - -// year, week := t.ISOWeek() - -// hours := calculateHoursDiff(startTime, endTime) - -// key := fmt.Sprintf("%d_%d_%d", userID, year, week) - -// if existing, exists := hoursMap[key]; exists { -// existing.TotalHours += hours -// } else { -// hoursMap[key] = &WeeklyHours{ -// UserID: userID, -// Username: username, -// Year: year, -// Week: week, -// TotalHours: hours, -// } -// } -// } - -// var result []WeeklyHours -// for _, h := range hoursMap { -// result = append(result, *h) -// } - -// sort.Slice(result, func(i, j int) bool { -// if result[i].Year != result[j].Year { -// return result[i].Year > result[j].Year -// } -// if result[i].Week != result[j].Week { -// return result[i].Week > result[j].Week -// } -// return result[i].Username < result[j].Username -// }) - -// return result, nil -// } func calculateHoursDiff(startTime, endTime string) float64 { parseTime := func(timeStr string) float64 { parts := strings.Split(timeStr, ":") @@ -489,14 +367,6 @@ func calculateHoursDiff(startTime, endTime string) float64 { return 0 } -// func DeleteUser(db *sql.DB, id int) error { -// if id == 1 { -// return fmt.Errorf("cannot delete admin user") -// } -// _, err := db.Exec("DELETE FROM users WHERE id = ?", id) -// return err -// } - func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error { dates := calculateWeekDates(year, week) diff --git a/backend/load-env.sh b/backend/load-env.sh new file mode 100755 index 0000000..7358e39 --- /dev/null +++ b/backend/load-env.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +if [ -f .env ]; then + set -a + source .env + set +a + echo "✅ .env geladen" +else + echo "❌ .env Datei nicht gefunden!" + exit 1 +fi + +if [ -z "$PORT" ]; then + export PORT=8080 +fi + +if [ -z "$DB_PATH" ]; then + export DB_PATH="/data/timetracking.db" +fi + +exec "$@" + diff --git a/backend/middleware.go b/backend/middleware.go index 1b4967d..857b9d0 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -7,14 +7,25 @@ import ( "encoding/json" "fmt" "net/http" + "os" "strings" + "sync" "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "golang.org/x/time/rate" ) -var jwtSecret = []byte("your-secret-key-change-in-production") +var jwtSecret []byte + +func init() { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + panic("JWT_SECRET environment variable is required") + } + jwtSecret = []byte(secret) +} func createToken(userID int, username string, isAdmin bool) (string, error) { claims := Claims{ @@ -29,7 +40,7 @@ func createToken(userID int, username string, isAdmin bool) (string, error) { "user_id": claims.UserID, "username": claims.Username, "is_admin": claims.IsAdmin, - "exp": time.Now().Add(24 * time.Hour).Unix(), + "exp": time.Now().Add(2 * time.Hour).Unix(), } payload, _ := json.Marshal(claimsWithExp) @@ -89,13 +100,13 @@ func JWTMiddleware() echo.MiddlewareFunc { return func(c echo.Context) error { authHeader := c.Request().Header.Get("Authorization") if authHeader == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header") + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } tokenString := strings.TrimPrefix(authHeader, "Bearer ") claims, err := verifyToken(tokenString) if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } c.Set("user_id", claims.UserID) @@ -114,7 +125,7 @@ func AdminMiddleware() echo.MiddlewareFunc { return func(c echo.Context) error { isAdmin, ok := c.Get("is_admin").(bool) if !ok || !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "admin access required") + return echo.NewHTTPError(http.StatusForbidden, "Access denied") } return next(c) } @@ -126,3 +137,69 @@ func CustomLogger() echo.MiddlewareFunc { Format: "${time_rfc3339} | ${status} | ${latency_human} | ${method} ${uri}\n", }) } + +type LoginRateLimiter struct { + limiters map[string]*rate.Limiter + mu sync.Mutex +} + +func NewLoginRateLimiter() *LoginRateLimiter { + limiter := &LoginRateLimiter{ + limiters: make(map[string]*rate.Limiter), + } + + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + limiter.mu.Lock() + limiter.limiters = make(map[string]*rate.Limiter) + limiter.mu.Unlock() + } + }() + + return limiter +} + +func (l *LoginRateLimiter) GetLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + limiter, exists := l.limiters[ip] + if !exists { + limiter = rate.NewLimiter(rate.Every(time.Minute/5), 5) + l.limiters[ip] = limiter + } + + return limiter +} + +func (l *LoginRateLimiter) Middleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ip := c.RealIP() + limiter := l.GetLimiter(ip) + + if !limiter.Allow() { + return echo.NewHTTPError(http.StatusTooManyRequests, "Too many login attempts. Please try again later.") + } + + return next(c) + } + } +} + +func HTTPSRedirectMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Nur in Production aktivieren + if os.Getenv("ENVIRONMENT") == "production" { + if c.Request().Header.Get("X-Forwarded-Proto") != "https" { + return c.Redirect(http.StatusMovedPermanently, + "https://"+c.Request().Host+c.Request().RequestURI) + } + } + return next(c) + } + } +} diff --git a/backend/static/index.html b/backend/static/index.html index 426d625..6be48fa 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -2,35 +2,148 @@ - - Schulzeit Erfassung + + + Zeiterfassung + + + + -
+
+ diff --git a/frontend/public/index.html b/frontend/public/index.html index 426d625..71337d4 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -2,35 +2,163 @@ - - Schulzeit Erfassung + + + Zeiterfassung + + + + + + -
+
+ diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 3eb4b28..c7716ce 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -14,15 +14,14 @@ import Dict exposing (Dict) -- PORTS -port saveToken : String -> Cmd msg +port saveToken : Encode.Value -> Cmd msg port removeToken : () -> Cmd msg - port confirmDelete : String -> Cmd msg port confirmDeleteResponse : (Bool -> msg) -> Sub msg -- MAIN -main : Program (Maybe String) Model Msg +main : Program Flags Model Msg main = Browser.element { init = init @@ -31,6 +30,11 @@ main = , view = view } +-- FLAGS +type alias Flags = + { token : Maybe String + , isAdmin : Bool + } -- MODEL @@ -56,17 +60,19 @@ type alias Model = , error : Maybe String , weekEditMode : Bool , hasEntriesForCurrentWeek : Bool - , userWeeklySummary : Maybe WeeklySummary -- NEU - , editingTimeEntryId : Maybe Int -- NEU - , editingTimeEntry : EditingTimeEntry -- NEU - , editingUserId : Maybe Int -- NEU - , editingUserWorkHours : String -- NEU - , resetPasswordUserId : Maybe Int -- NEU - , resetPasswordNew : String -- NEU - , pendingDeleteId : Maybe Int -- NEU: Speichert die ID die gelöscht werden soll - , selectedUserId : Maybe Int -- NEU + , userWeeklySummary : Maybe WeeklySummary + , editingTimeEntryId : Maybe Int + , editingTimeEntry : EditingTimeEntry + , editingUserId : Maybe Int + , editingUserWorkHours : String + , resetPasswordUserId : Maybe Int + , resetPasswordNew : String + , pendingDeleteId : Maybe Int + , selectedUserId : Maybe Int , userWorkHoursInput : String , userPasswordInput : String + , isProcessing : Bool + , mobileMenuOpen : Bool } type Page @@ -92,7 +98,7 @@ type alias User = { id : Int , username : String , isAdmin : Bool - , weeklyWorkHours : Float -- NEU + , weeklyWorkHours : Float } type alias TimeEntry = @@ -160,16 +166,23 @@ type alias WeeklyHours = , remainingHours : Float } -init : Maybe String -> (Model, Cmd Msg) -init storedToken = +init : Flags -> (Model, Cmd Msg) +init flags = let + initialPage = + case flags.token of + Just _ -> + if flags.isAdmin then AdminDashboard else UserDashboard + Nothing -> + LoginPage + model = - { page = if storedToken /= Nothing then UserDashboard else LoginPage + { page = initialPage , activeTab = ScheduleTab , username = "" , password = "" - , token = storedToken - , isAdmin = False + , token = flags.token + , isAdmin = flags.isAdmin , schedules = [] , users = [] , timeEntries = [] @@ -185,21 +198,23 @@ init storedToken = , weekEditMode = False , hasEntriesForCurrentWeek = False , weekDates = Nothing - , userWeeklySummary = Nothing -- NEU - , editingTimeEntryId = Nothing -- NEU - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" -- NEU - , editingUserId = Nothing -- NEU - , editingUserWorkHours = "" -- NEU - , resetPasswordUserId = Nothing -- NEU - , resetPasswordNew = "" -- NEU - , pendingDeleteId = Nothing -- NEU! - , selectedUserId = Nothing -- NEU + , userWeeklySummary = Nothing + , editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + , editingUserId = Nothing + , editingUserWorkHours = "" + , resetPasswordUserId = Nothing + , resetPasswordNew = "" + , pendingDeleteId = Nothing + , selectedUserId = Nothing , userWorkHoursInput = "" , userPasswordInput = "" + , isProcessing = False + , mobileMenuOpen = False } cmd = - case storedToken of + case flags.token of Just token -> Cmd.batch [ Task.perform SetTime Time.now @@ -259,30 +274,30 @@ type Msg | WeekDatesReceived (Result Http.Error WeekDates) | CheckWeekHasEntries | WeekHasEntriesReceived (Result Http.Error Bool) - | FetchMyWeeklySummary -- NEU - | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) -- NEU - | EditTimeEntry Int -- NEU - | CancelEditTimeEntry -- NEU - | UpdateEditTimeEntryDate String -- NEU - | UpdateEditTimeEntryStartTime String -- NEU - | UpdateEditTimeEntryEndTime String -- NEU - | UpdateEditTimeEntryType String -- NEU - | SaveEditTimeEntry -- NEU - | TimeEntrySaved (Result Http.Error ()) -- NEU - | TimeEntryDeleted (Result Http.Error ()) -- NEU - | EditUserWorkHours Int -- NEU - | CancelEditUserWorkHours -- NEU - | UpdateEditUserWorkHours String -- NEU - | SaveUserWorkHours -- NEU - | UserWorkHoursSaved (Result Http.Error ()) -- NEU - | ResetUserPassword Int -- NEU - | CancelResetPassword -- NEU - | UpdateResetPasswordNew String -- NEU - | SaveResetPassword -- NEU - | ResetPasswordSaved (Result Http.Error ()) -- NEU - | ConfirmDeleteTimeEntry Int -- NEU - | ConfirmDeleteUser Int -- NEU - | DeleteConfirmed Bool -- NEU + | FetchMyWeeklySummary + | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) + | EditTimeEntry Int + | CancelEditTimeEntry + | UpdateEditTimeEntryDate String + | UpdateEditTimeEntryStartTime String + | UpdateEditTimeEntryEndTime String + | UpdateEditTimeEntryType String + | SaveEditTimeEntry + | TimeEntrySaved (Result Http.Error ()) + | TimeEntryDeleted (Result Http.Error ()) + | EditUserWorkHours Int + | CancelEditUserWorkHours + | UpdateEditUserWorkHours String + | SaveUserWorkHours + | UserWorkHoursSaved (Result Http.Error ()) + | ResetUserPassword Int + | CancelResetPassword + | UpdateResetPasswordNew String + | SaveResetPassword + | ResetPasswordSaved (Result Http.Error ()) + | ConfirmDeleteTimeEntry Int + | ConfirmDeleteUser Int + | DeleteConfirmed Bool | StartEditingTimeEntry Int TimeEntry | CancelEditingTimeEntry | UpdateEditingTimeEntryDate String @@ -295,10 +310,18 @@ type Msg | UpdateUserPassword String | SaveUserPassword | UserPasswordSaved (Result Http.Error ()) + | ToggleMobileMenu + | CloseMobileMenu update : Msg -> Model -> (Model, Cmd Msg) update msg model = case msg of + ToggleMobileMenu -> + ({ model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none) + + CloseMobileMenu -> + ({ model | mobileMenuOpen = False }, Cmd.none) + UpdateUsername username -> ({ model | username = username }, Cmd.none) @@ -306,13 +329,21 @@ update msg model = ({ model | password = password }, Cmd.none) Login -> - (model, loginRequest model.username model.password) + if model.isProcessing then + (model, Cmd.none) + else + ({ model | isProcessing = True }, loginRequest model.username model.password) LoginResponse (Ok result) -> let newPage = if result.isAdmin then AdminDashboard else UserDashboard (year, week) = getISOWeekFromPosix model.currentTime + + tokenData = Encode.object + [ ("token", Encode.string result.token) + , ("isAdmin", Encode.bool result.isAdmin) + ] in ({ model | token = Just result.token @@ -320,27 +351,28 @@ update msg model = , isAdmin = result.isAdmin , page = newPage , error = Nothing + , isProcessing = False }, Cmd.batch - [ saveToken result.token + [ saveToken tokenData , fetchSchedules (Just result.token) , if not result.isAdmin then Cmd.batch [ fetchMyTimeEntries result.token , fetchWeekDates result.token year week , checkWeekHasEntries result.token year week - , fetchMyWeeklySummary result.token year week -- NEU! + , fetchMyWeeklySummary result.token year week ] else Cmd.batch [ fetchMyTimeEntries result.token , fetchWeekDates result.token year week , checkWeekHasEntries result.token year week - , fetchMyWeeklySummary result.token year week -- NEU! + , fetchMyWeeklySummary result.token year week ] ]) LoginResponse (Err _) -> - ({ model | error = Just "Login fehlgeschlagen" }, Cmd.none) + ({ model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none) Logout -> ({ model @@ -349,6 +381,7 @@ update msg model = , isAdmin = False , username = "" , password = "" + , isProcessing = False }, removeToken ()) FetchSchedules -> @@ -389,7 +422,7 @@ update msg model = , hasEntriesForCurrentWeek = True }, Cmd.batch [ fetchMyTimeEntries token - , fetchMyWeeklySummary token model.currentYear model.currentWeek -- NEU! + , fetchMyWeeklySummary token model.currentYear model.currentWeek ]) Nothing -> (model, Cmd.none) @@ -411,7 +444,7 @@ update msg model = Cmd.batch [ fetchWeekDates token newYear newWeek , checkWeekHasEntries token newYear newWeek - , fetchMyWeeklySummary token newYear newWeek -- NEU! + , fetchMyWeeklySummary token newYear newWeek ] Nothing -> Cmd.none @@ -431,7 +464,7 @@ update msg model = Cmd.batch [ fetchWeekDates token newYear newWeek , checkWeekHasEntries token newYear newWeek - , fetchMyWeeklySummary token newYear newWeek -- NEU! + , fetchMyWeeklySummary token newYear newWeek ] Nothing -> Cmd.none @@ -474,7 +507,7 @@ update msg model = [ checkWeekHasEntries token year week , fetchWeekDates token year week , fetchMyTimeEntries token - , fetchMyWeeklySummary token year week -- NEU! + , fetchMyWeeklySummary token year week ] else Cmd.none @@ -564,7 +597,7 @@ update msg model = _ -> Cmd.none in - ({ model | activeTab = tab }, cmd) + ({ model | activeTab = tab, mobileMenuOpen = False}, cmd) UpdateNewScheduleDay day -> let @@ -602,20 +635,45 @@ update msg model = ({ model | newSchedule = newSchedule }, Cmd.none) CreateSchedule -> + if String.isEmpty model.newSchedule.dayOfWeek || + String.isEmpty model.newSchedule.startTime || + String.isEmpty model.newSchedule.endTime then + ({ model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none) + else + case model.token of + Just token -> + ({ model | isProcessing = True }, createSchedule token model.newSchedule) + Nothing -> + (model, Cmd.none) + + ScheduleCreated (Ok _) -> case model.token of Just token -> - (model, createSchedule token model.newSchedule) + let + emptySchedule = NewSchedule "" "" "" "lesson" "" + in + ({ model + | newSchedule = emptySchedule + , error = Nothing + , isProcessing = False + }, fetchSchedules model.token) + Nothing -> (model, Cmd.none) - ScheduleCreated (Ok _) -> + ScheduleCreated (Err err) -> let - emptySchedule = NewSchedule "" "" "" "lesson" "" + 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 | newSchedule = emptySchedule }, fetchSchedules model.token) - - ScheduleCreated (Err _) -> - ({ model | error = Just "Fehler beim Erstellen" }, Cmd.none) + ({ model + | error = Just errorMsg + , isProcessing = False + }, Cmd.none) DeleteSchedule scheduleId -> case model.token of @@ -989,7 +1047,7 @@ update msg model = ({ model | userWorkHoursInput = input }, Cmd.none) SaveUserWorkHours -> - case (model.token, model.editingUserId, String.toFloat model.editingUserWorkHours) of -- ← Änderungen! + case (model.token, model.editingUserId, String.toFloat model.editingUserWorkHours) of (Just token, Just userId, Just hours) -> (model, updateUserWorkHours token userId (String.fromFloat hours)) _ -> @@ -999,28 +1057,12 @@ update msg model = case model.token of Just token -> ({ model - | editingUserWorkHours = "" -- ← Änderung - , editingUserId = Nothing -- ← Änderung + | editingUserWorkHours = "" + , editingUserId = Nothing , error = Nothing }, fetchUsers token) Nothing -> (model, Cmd.none) - -- SaveUserWorkHours -> - -- case (model.token, model.selectedUserId, String.toFloat model.userWorkHoursInput) of - -- (Just token, Just userId, Just hours) -> - -- (model, updateUserWorkHours token userId (String.fromFloat hours)) - -- _ -> - -- ({ model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none) - - -- UserWorkHoursSaved (Ok _) -> - -- case model.token of - -- Just token -> - -- ({ model - -- | userWorkHoursInput = "" - -- , error = Nothing - -- }, fetchUsers token) - -- Nothing -> - -- (model, Cmd.none) UserWorkHoursSaved (Err _) -> ({ model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none) @@ -1055,7 +1097,7 @@ update msg model = subscriptions : Model -> Sub Msg subscriptions model = - confirmDeleteResponse DeleteConfirmed -- NEU + confirmDeleteResponse DeleteConfirmed -- HELPER FUNCTIONS @@ -1324,18 +1366,38 @@ 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 "")) + , attribute "role" "navigation" + , attribute "aria-label" "menu" + , attribute "aria-expanded" (if model.mobileMenuOpen then "true" else "false") + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarUser" + , class ("navbar-menu" ++ (if model.mobileMenuOpen then " is-active" else "")) -- NEU! ] - , div [ class "navbar-menu" ] [ div [ class "navbar-end" ] [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-4" ] [ text model.username ] - , button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] ] ] ] @@ -1360,6 +1422,7 @@ viewUserDashboard model = [ button [ class "button is-warning" , onClick EnableEditMode + , disabled model.isProcessing ] [ text "Bearbeiten" ] ] ] @@ -1380,6 +1443,7 @@ viewUserDashboard model = [ button [ class "button is-danger is-small mr-2" , onClick DeleteWeekEntries + , disabled model.isProcessing ] [ text "Einträge löschen" ] , button [ class "button is-light is-small" @@ -1400,8 +1464,14 @@ viewUserDashboard model = [ button [ class "button is-primary is-large is-fullwidth" , onClick SaveTimeEntries - , disabled (List.isEmpty model.selectedEntries) - ] [ text (if model.weekEditMode then "Änderungen speichern" else "Speichern") ] + , disabled (List.isEmpty model.selectedEntries || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + else + text "" + , text (if model.weekEditMode then "Änderungen speichern" else "Speichern") + ] ] ] else @@ -1418,6 +1488,151 @@ viewUserDashboard model = ] ] +viewAdminDashboard : Model -> Html Msg +viewAdminDashboard model = + div [] + [ nav [ class "navbar is-danger" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] + ] + , a + [ class ("navbar-burger" ++ (if model.mobileMenuOpen then " is-active" else "")) + , attribute "aria-label" "menu" + , attribute "aria-expanded" (if model.mobileMenuOpen then "true" else "false") + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarAdmin" + , class ("navbar-menu" ++ (if model.mobileMenuOpen then " is-active" else "")) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ div [ class "tabs is-boxed" ] + [ ul [] + [ li [ classList [("is-active", model.activeTab == ScheduleTab)] ] + [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] + , li [ classList [("is-active", model.activeTab == UsersTab)] ] + [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] + , li [ classList [("is-active", model.activeTab == TimeEntriesTab)] ] + [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] + ] + ] + , 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 = + if isSelected then + "box has-background-success-light" + else if isClickable then + "box has-background-white" + else + "box has-background-light" + + typeText = if schedule.scheduleType == "break" then " (Pause)" else "" + + cursorStyle = if isClickable then "pointer" else "not-allowed" + opacity = if isClickable || isSelected then "1" else "0.6" + in + div + [ class boxClass + , onClick (if isClickable then ToggleScheduleSelection schedule.id dayOfWeek else FetchSchedules) + , style "cursor" cursorStyle + , style "margin-bottom" "0.5rem" + , style "padding" "0.75rem" + , style "opacity" opacity + , style "transition" "all 0.2s ease" + , style "border" (if isClickable && not isSelected then "2px solid transparent" else "2px solid currentColor") + ] + [ p [ class "has-text-weight-bold is-size-7" ] + [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , p [ class "is-size-7" ] + [ text (schedule.title ++ typeText) ] + ] + +viewScheduleGridWithWeek : Model -> Html Msg +viewScheduleGridWithWeek model = + let + days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"] + + groupedSchedules = List.range 0 4 + |> List.map (\day -> + ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) + ) + in + div [] + [ + div [ class "is-hidden-mobile" ] + [ div [ class "table-container" ] + [ table [ class "table is-bordered is-fullwidth" ] + [ thead [] + [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) + ] + , tbody [] + [ tr [] + (List.map (viewDayColumnWithWeek model) groupedSchedules) + ] + ] + ] + ] + , div [ class "is-hidden-tablet" ] + (List.map2 (viewDayMobile model) days groupedSchedules) + ] + +viewDayMobile : Model -> String -> (Int, List Schedule) -> Html Msg +viewDayMobile model dayName (dayOfWeek, schedules) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\(day, _) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + Nothing -> + "Laden..." + in + div [ class "box mb-4" ] + [ p [ class "has-text-weight-bold has-text-centered mb-3" ] + [ text (dayName ++ " - " ++ dateForDay) ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] + viewUserWeeklySummary : Model -> Html Msg viewUserWeeklySummary model = case model.userWeeklySummary of @@ -1462,46 +1677,6 @@ viewUserWeeklySummary model = [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] ] -viewAdminDashboard : Model -> Html Msg -viewAdminDashboard model = - div [] - [ nav [ class "navbar is-danger" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] - ] - ] - , div [ class "navbar-menu" ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-4" ] [ text model.username ] - , button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ div [ class "tabs is-boxed" ] - [ ul [] - [ li [ classList [("is-active", model.activeTab == ScheduleTab)] ] - [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] - , li [ classList [("is-active", model.activeTab == UsersTab)] ] - [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] - , li [ classList [("is-active", model.activeTab == TimeEntriesTab)] ] - [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] - ] - ] - , case model.activeTab of - ScheduleTab -> - viewScheduleTab model - UsersTab -> - viewUsersTab model - TimeEntriesTab -> - viewTimeEntriesTab model - ] - ] - ] viewScheduleTab : Model -> Html Msg viewScheduleTab model = @@ -1534,7 +1709,6 @@ viewTimeEntriesTab model = viewTimeEntriesListWithEdit model ] --- Separate Edit Form View viewTimeEntriesEditForm : Model -> Html Msg viewTimeEntriesEditForm model = div [ class "box has-background-warning-light" ] @@ -1649,7 +1823,6 @@ viewTimeEntryRowWithEdit model entry = isEditing = model.editingTimeEntryId == Just entry.id in if isEditing then - -- Edit-Modus tr [] [ td [] [ text entry.username ] , td [] @@ -1752,28 +1925,6 @@ viewWeekNavigation model = ] ] -viewScheduleGridWithWeek : Model -> Html Msg -viewScheduleGridWithWeek model = - let - days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"] - - groupedSchedules = List.range 0 4 - |> List.map (\day -> - ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) - ) - in - div [ class "table-container" ] - [ table [ class "table is-bordered is-fullwidth" ] - [ thead [] - [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) - ] - , tbody [] - [ tr [] - (List.map (viewDayColumnWithWeek model) groupedSchedules) - ] - ] - ] - viewDayColumnWithWeek : Model -> (Int, List Schedule) -> Html Msg viewDayColumnWithWeek model (dayOfWeek, schedules) = let @@ -1794,37 +1945,6 @@ viewDayColumnWithWeek model (dayOfWeek, schedules) = , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) ] -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 - - boxClass = - if isSelected then - "box has-background-success-light" - else - "box has-background-white" - - typeText = if schedule.scheduleType == "break" then " (Pause)" else "" - - cursorStyle = if isClickable then "pointer" else "not-allowed" - opacity = if isClickable || isSelected then "1" else "0.6" - in - div - [ class boxClass - , onClick (if isClickable then ToggleScheduleSelection schedule.id dayOfWeek else CheckWeekHasEntries) -- Dummy-Event wenn nicht klickbar - , style "cursor" cursorStyle - , style "margin-bottom" "0.5rem" - , style "padding" "0.75rem" - , style "opacity" opacity - ] - [ p [ class "has-text-weight-bold is-size-7" ] - [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [ class "is-size-7" ] - [ text (schedule.title ++ typeText) ] - ] viewScheduleForm : Model -> Html Msg viewScheduleForm model = @@ -1835,7 +1955,11 @@ viewScheduleForm model = [ label [ class "label" ] [ text "Wochentag" ] , div [ class "control" ] [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateNewScheduleDay ] + [ select + [ onInput UpdateNewScheduleDay + , disabled model.isProcessing + , value model.newSchedule.dayOfWeek + ] [ option [ value "" ] [ text "Wochentag wählen" ] , option [ value "0" ] [ text "Montag" ] , option [ value "1" ] [ text "Dienstag" ] @@ -1856,6 +1980,7 @@ viewScheduleForm model = , type_ "time" , value model.newSchedule.startTime , onInput UpdateNewScheduleStart + , disabled model.isProcessing ] [] ] ] @@ -1869,6 +1994,7 @@ viewScheduleForm model = , type_ "time" , value model.newSchedule.endTime , onInput UpdateNewScheduleEnd + , disabled model.isProcessing ] [] ] ] @@ -1880,7 +2006,11 @@ viewScheduleForm model = [ label [ class "label" ] [ text "Typ" ] , div [ class "control" ] [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateNewScheduleType, value model.newSchedule.scheduleType ] + [ select + [ onInput UpdateNewScheduleType + , value model.newSchedule.scheduleType + , disabled model.isProcessing + ] [ option [ value "lesson" ] [ text "Unterricht" ] , option [ value "break" ] [ text "Pause" ] ] @@ -1898,6 +2028,7 @@ viewScheduleForm model = , placeholder "z.B. Mathematik" , value model.newSchedule.title , onInput UpdateNewScheduleTitle + , disabled model.isProcessing ] [] ] ] @@ -1905,9 +2036,23 @@ viewScheduleForm model = ] , div [ class "field" ] [ div [ class "control" ] - [ button [ class "button is-primary", onClick CreateSchedule ] [ text "Hinzufügen" ] + [ button + [ class "button is-primary" + , onClick CreateSchedule + , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + else + text "" + , text " Hinzufügen" + ] ] ] + , if String.isEmpty model.newSchedule.dayOfWeek then + div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] + else + text "" ] viewScheduleList : Model -> Html Msg @@ -2035,7 +2180,6 @@ viewUserList model = viewUserRowWithActions : Model -> User -> Html Msg viewUserRowWithActions model user = if model.editingUserId == Just user.id then - -- Edit Work Hours Mode tr [] [ td [] [ text (String.fromInt user.id) ] , td [] [ text user.username ] @@ -2207,7 +2351,7 @@ viewTimeEntriesList model = ] ] , tbody [] - (List.map (viewTimeEntryRowWithActions model) filteredEntries) -- KORRIGIERT: model übergeben + (List.map (viewTimeEntryRowWithActions model) filteredEntries) ] ] @@ -2490,8 +2634,8 @@ weeklyHoursDecoder = (field "year" int) (field "week" int) (field "total_hours" float) - (field "expected_hours" float) -- NEU - (field "remaining_hours" float) -- NEU + (field "expected_hours" float) + (field "remaining_hours" float) fetchWeekDates : String -> Int -> Int -> Cmd Msg fetchWeekDates token year week = From e65ba85c4337bd0f86e15f68da5996efb3307db1 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 5 Nov 2025 23:39:51 +0100 Subject: [PATCH 02/13] feat: added all features for seconde release candidate --- backend/database.go | 161 ++- backend/go.mod | 2 +- backend/handlers.go | 120 +- backend/main.go | 6 +- backend/models.go | 22 +- docker-compose.yml | 17 +- frontend/src/Main.elm | 2469 +++++++++++++++++++++++++++++------------ 7 files changed, 1947 insertions(+), 850 deletions(-) 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 + } From c07019e3ebf6d3f79bc33d25a7c75e638fd5ff6b Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Thu, 6 Nov 2025 07:18:23 +0100 Subject: [PATCH 03/13] feat: add schoolyear based calculation --- backend/database.go | 223 +++++++++++++++++----- backend/handlers.go | 48 +++++ backend/main.go | 4 + backend/models.go | 15 ++ frontend/src/Main.elm | 429 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 675 insertions(+), 44 deletions(-) diff --git a/backend/database.go b/backend/database.go index 123adee..6897bd9 100644 --- a/backend/database.go +++ b/backend/database.go @@ -66,6 +66,14 @@ func createTables(db *sql.DB) { details TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, + `CREATE TABLE IF NOT EXISTS school_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, } for _, query := range queries { @@ -92,6 +100,8 @@ func createIndexes(db *sql.DB) { `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`, `CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`, `CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`, } for _, idx := range indexes { @@ -349,56 +359,56 @@ 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 - } +// 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 - } +// entries, err := GetAllTimeEntries(db) +// if err != nil { +// return nil, err +// } - userTotals := make(map[int]float64) - usernames := make(map[int]string) +// 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 - } +// 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 +// 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, - }) - } - } +// 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 - }) +// sort.Slice(result, func(i, j int) bool { +// return result[i].Username < result[j].Username +// }) - return result, nil -} +// return result, nil +// } func calculateHoursDiff(startTime, endTime string) float64 { parseTime := func(timeStr string) float64 { @@ -469,3 +479,130 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo return count > 0, nil } + +func GetActiveSchoolYear(db *sql.DB) (*SchoolYear, error) { + var sy SchoolYear + err := db.QueryRow(` + SELECT id, name, start_date, end_date, is_active, created_at + FROM school_years + WHERE is_active = 1 + `).Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt) + + if err == sql.ErrNoRows { + return nil, nil // Kein aktives Schuljahr + } + return &sy, err +} + +func GetAllSchoolYears(db *sql.DB) ([]SchoolYear, error) { + rows, err := db.Query(` + SELECT id, name, start_date, end_date, is_active, created_at + FROM school_years + ORDER BY start_date DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + years := []SchoolYear{} + for rows.Next() { + var sy SchoolYear + if err := rows.Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt); err != nil { + continue + } + years = append(years, sy) + } + return years, rows.Err() +} + +func CreateSchoolYear(db *sql.DB, name, startDate, endDate string) error { + _, err := db.Exec(` + INSERT INTO school_years (name, start_date, end_date, is_active) + VALUES (?, ?, ?, 0) + `, name, startDate, endDate) + return err +} + +func SetActiveSchoolYear(db *sql.DB, id int) error { + tx, err := db.Begin() + if err != nil { + return err + } + + if _, err := tx.Exec("UPDATE school_years SET is_active = 0"); err != nil { + tx.Rollback() + return err + } + + if _, err := tx.Exec("UPDATE school_years SET is_active = 1 WHERE id = ?", id); err != nil { + tx.Rollback() + return err + } + + return tx.Commit() +} + +func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) { + schoolYear, err := GetActiveSchoolYear(db) + if err != nil || schoolYear == nil { + return []WeeklyHours{}, err + } + + users, err := GetAllUsers(db) + if err != nil { + return []WeeklyHours{}, err + } + + rows, err := db.Query(` + SELECT user_id, date, start_time, end_time, type + FROM time_entries + WHERE date >= ? AND date <= ? + ORDER BY date DESC + `, schoolYear.StartDate, schoolYear.EndDate) + + if err != nil { + return []WeeklyHours{}, err + } + defer rows.Close() + + userTotals := make(map[int]float64) + + for rows.Next() { + var userID int + var date, startTime, endTime, entryType string + + if err := rows.Scan(&userID, &date, &startTime, &endTime, &entryType); err != nil { + continue + } + + var hours float64 + if entryType == "lesson" { + hours = 1.0 + } else { + hours = calculateHoursDiff(startTime, endTime) + } + userTotals[userID] += hours + } + + var result []WeeklyHours + for _, user := range users { + if !user.IsAdmin { + total := userTotals[user.ID] + remaining := user.YearlyHours - total + + result = append(result, WeeklyHours{ + UserID: user.ID, + Username: user.Username, + Year: 0, + Week: 0, + TotalHours: total, + YearlyTarget: user.YearlyHours, + YearlyActual: total, + RemainingYearly: remaining, + }) + } + } + + return result, nil +} diff --git a/backend/handlers.go b/backend/handlers.go index cd5713c..e87b5f3 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -448,3 +448,51 @@ func (app *App) CreateUserHandler(c echo.Context) error { return c.NoContent(http.StatusCreated) } + +func (app *App) GetSchoolYearsHandler(c echo.Context) error { + years, err := GetAllSchoolYears(app.DB) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if years == nil { + years = []SchoolYear{} + } + return c.JSON(http.StatusOK, years) +} + +func (app *App) CreateSchoolYearHandler(c echo.Context) error { + var req CreateSchoolYearRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusCreated) +} + +func (app *App) SetActiveSchoolYearHandler(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID") + } + + if err := SetActiveSchoolYear(app.DB, id); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusNoContent) +} + +func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { + year, err := GetActiveSchoolYear(app.DB) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + if year == nil { + return c.JSON(http.StatusOK, map[string]any{"active": false}) + } + return c.JSON(http.StatusOK, year) +} diff --git a/backend/main.go b/backend/main.go index f09a17a..bdf47b5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -46,6 +46,7 @@ func main() { protected.GET("/week-has-entries", app.CheckWeekHasEntries) protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler) protected.GET("/my-info", app.GetMyInfoHandler) + protected.GET("/school-year/active", app.GetActiveSchoolYearHandler) } admin := e.Group("/api/admin") @@ -64,6 +65,9 @@ func main() { admin.PUT("/time-entries/:id", app.UpdateTimeEntryHandler) admin.DELETE("/time-entries/:id", app.DeleteTimeEntryHandler) admin.POST("/time-entry", app.AdminCreateTimeEntryHandler) + admin.GET("/school-years", app.GetSchoolYearsHandler) + admin.POST("/school-years", app.CreateSchoolYearHandler) + admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler) } e.Static("/", "./static") diff --git a/backend/models.go b/backend/models.go index 6ca8f71..1348146 100644 --- a/backend/models.go +++ b/backend/models.go @@ -61,6 +61,21 @@ type CreateUserRequest struct { YearlyHours float64 `json:"yearly_hours"` } +type SchoolYear struct { + ID int `json:"id"` + Name string `json:"name"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateSchoolYearRequest struct { + Name string `json:"name" validate:"required"` + StartDate string `json:"start_date" validate:"required"` + EndDate string `json:"end_date" validate:"required"` +} + type UpdateUserRequest struct { Username string `json:"username"` YearlyHours float64 `json:"yearly_hours"` diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 746031f..4afe315 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -93,6 +93,10 @@ type alias Model = , isProcessing : Bool , mobileMenuOpen : Bool , adminManualEntryForm : AdminManualEntry + , schoolYears : List SchoolYear + , newSchoolYear : NewSchoolYear + , activeSchoolYear : Maybe SchoolYear + , editingSchoolYearId : Maybe Int } @@ -106,6 +110,7 @@ type AdminTab = ScheduleTab | UsersTab | TimeEntriesTab + | SchoolYearsTab type alias Schedule = @@ -221,6 +226,22 @@ type alias AdminManualEntry = } +type alias SchoolYear = + { id : Int + , name : String + , startDate : String + , endDate : String + , isActive : Bool + } + + +type alias NewSchoolYear = + { name : String + , startDate : String + , endDate : String + } + + init : Flags -> ( Model, Cmd Msg ) init flags = let @@ -273,6 +294,10 @@ init flags = , isProcessing = False , mobileMenuOpen = False , adminManualEntryForm = AdminManualEntry Nothing "" "" "" "lesson" + , schoolYears = [] + , newSchoolYear = NewSchoolYear "" "" "" + , activeSchoolYear = Nothing + , editingSchoolYearId = Nothing } cmd = @@ -283,7 +308,7 @@ init flags = , fetchSchedules (Just token) , fetchYearlyHoursSummary token , if flags.isAdmin then - Cmd.none + fetchSchoolYears token else fetchMyInfo token @@ -394,6 +419,19 @@ type Msg | AdminTimeEntrySaved (Result Http.Error ()) | FetchMyInfo | MyInfoReceived (Result Http.Error User) + | FetchSchoolYears + | SchoolYearsReceived (Result Http.Error (List SchoolYear)) + | FetchActiveSchoolYear + | ActiveSchoolYearReceived (Result Http.Error SchoolYear) + | UpdateNewSchoolYearName String + | UpdateNewSchoolYearStart String + | UpdateNewSchoolYearEnd String + | CreateSchoolYear + | SchoolYearCreated (Result Http.Error ()) + | ActivateSchoolYear Int + | SchoolYearActivated (Result Http.Error ()) + | DeleteSchoolYear Int + | SchoolYearDeleted (Result Http.Error ()) update : Msg -> Model -> ( Model, Cmd Msg ) @@ -732,6 +770,17 @@ update msg model = Nothing -> Cmd.none + SchoolYearsTab -> + case model.token of + Just token -> + Cmd.batch + [ fetchSchoolYears token + , fetchActiveSchoolYear token + ] + + Nothing -> + Cmd.none + _ -> Cmd.none in @@ -1452,6 +1501,145 @@ update msg model = MyInfoReceived (Err _) -> ( { model | error = Just "Fehler beim Laden deiner Daten" }, Cmd.none ) + FetchSchoolYears -> + case model.token of + Just token -> + ( model, fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearsReceived (Ok years) -> + ( { model | schoolYears = years }, Cmd.none ) + + SchoolYearsReceived (Err _) -> + ( { model | error = Just "Fehler beim Laden der Schuljahre" }, Cmd.none ) + + FetchActiveSchoolYear -> + case model.token of + Just token -> + ( model, fetchActiveSchoolYear token ) + + Nothing -> + ( model, Cmd.none ) + + ActiveSchoolYearReceived (Ok year) -> + ( { model | activeSchoolYear = Just year }, Cmd.none ) + + ActiveSchoolYearReceived (Err _) -> + ( { model | activeSchoolYear = Nothing }, Cmd.none ) + + UpdateNewSchoolYearName name -> + let + old = + model.newSchoolYear + + new = + { old | name = name } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + UpdateNewSchoolYearStart date -> + let + old = + model.newSchoolYear + + new = + { old | startDate = date } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + UpdateNewSchoolYearEnd date -> + let + old = + model.newSchoolYear + + new = + { old | endDate = date } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + CreateSchoolYear -> + if + String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + then + ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, createSchoolYear token model.newSchoolYear ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearCreated (Ok _) -> + case model.token of + Just token -> + ( { model + | newSchoolYear = NewSchoolYear "" "" "" + , error = Nothing + , isProcessing = False + } + , fetchSchoolYears token + ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearCreated (Err _) -> + ( { model + | error = Just "Fehler beim Erstellen des Schuljahres" + , isProcessing = False + } + , Cmd.none + ) + + ActivateSchoolYear id -> + case model.token of + Just token -> + ( model, activateSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearActivated (Ok _) -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ fetchSchoolYears token + , fetchActiveSchoolYear token + ] + ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearActivated (Err _) -> + ( { model | error = Just "Fehler beim Aktivieren" }, Cmd.none ) + + DeleteSchoolYear id -> + case model.token of + Just token -> + ( model, deleteSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearDeleted (Ok _) -> + case model.token of + Just token -> + ( { model | error = Nothing }, fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearDeleted (Err _) -> + ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + -- SUBSCRIPTIONS @@ -2115,6 +2303,8 @@ viewAdminDashboard model = [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] + , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ] + [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ] ] ] , case model.activeTab of @@ -2126,6 +2316,9 @@ viewAdminDashboard model = TimeEntriesTab -> viewTimeEntriesTab model + + SchoolYearsTab -> + viewSchoolYearsTab model ] ] ] @@ -3344,6 +3537,159 @@ viewTimeEntryRowWithActions model entry = ] +viewSchoolYearsTab : Model -> Html Msg +viewSchoolYearsTab model = + div [] + [ h2 [ class "title" ] [ text "Schuljahre verwalten" ] + , case model.activeSchoolYear of + Just schoolYear -> + div [ class "notification is-info is-light mb-4" ] + [ p [ class "has-text-weight-bold" ] + [ text ("Aktives Schuljahr: " ++ schoolYear.name) ] + , p [ class "is-size-7" ] + [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ] + ] + + Nothing -> + div [ class "notification is-warning is-light mb-4" ] + [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ] + , viewSchoolYearForm model + , viewSchoolYearsList model + ] + + +viewSchoolYearForm : Model -> Html Msg +viewSchoolYearForm model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "2024/2025" + , value model.newSchoolYear.name + , onInput UpdateNewSchoolYearName + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startdatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.startDate + , onInput UpdateNewSchoolYearStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Enddatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.endDate + , onInput UpdateNewSchoolYearEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchoolYear + , disabled + (String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + || model.isProcessing + ) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Schuljahr erstellen" + ] + ] + ] + ] + + +viewSchoolYearsList : Model -> Html Msg +viewSchoolYearsList model = + div [ class "box mt-4" ] + [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ] + , if List.isEmpty model.schoolYears then + p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Name" ] + , th [] [ text "Startdatum" ] + , th [] [ text "Enddatum" ] + , th [ class "has-text-centered" ] [ text "Status" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map viewSchoolYearRow model.schoolYears) + ] + ] + + +viewSchoolYearRow : SchoolYear -> Html Msg +viewSchoolYearRow schoolYear = + tr [] + [ td [] [ text schoolYear.name ] + , td [] [ text schoolYear.startDate ] + , td [] [ text schoolYear.endDate ] + , td [ class "has-text-centered" ] + [ if schoolYear.isActive then + span [ class "tag is-success" ] [ text "Aktiv" ] + + else + span [ class "tag is-light" ] [ text "Inaktiv" ] + ] + , td [ class "has-text-centered" ] + [ if not schoolYear.isActive then + button + [ class "button is-small is-info mr-2" + , onClick (ActivateSchoolYear schoolYear.id) + ] + [ text "Aktivieren" ] + + else + text "" + , button + [ class "button is-small is-danger" + , onClick (DeleteSchoolYear schoolYear.id) + ] + [ text "Löschen" ] + ] + ] + + -- HTTP @@ -3795,3 +4141,84 @@ fetchMyInfo token = , timeout = Nothing , tracker = Nothing } + + +fetchSchoolYears : String -> Cmd Msg +fetchSchoolYears token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years" + , body = Http.emptyBody + , expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchActiveSchoolYear : String -> Cmd Msg +fetchActiveSchoolYear token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/school-year/active" + , body = Http.emptyBody + , expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder + , timeout = Nothing + , tracker = Nothing + } + + +createSchoolYear : String -> NewSchoolYear -> Cmd Msg +createSchoolYear token schoolYear = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years" + , body = + Http.jsonBody <| + Encode.object + [ ( "name", Encode.string schoolYear.name ) + , ( "start_date", Encode.string schoolYear.startDate ) + , ( "end_date", Encode.string schoolYear.endDate ) + ] + , expect = Http.expectWhatever SchoolYearCreated + , timeout = Nothing + , tracker = Nothing + } + + +activateSchoolYear : String -> Int -> Cmd Msg +activateSchoolYear token id = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate" + , body = Http.emptyBody + , expect = Http.expectWhatever SchoolYearActivated + , timeout = Nothing + , tracker = Nothing + } + + +deleteSchoolYear : String -> Int -> Cmd Msg +deleteSchoolYear token id = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years/" ++ String.fromInt id + , body = Http.emptyBody + , expect = Http.expectWhatever SchoolYearDeleted + , timeout = Nothing + , tracker = Nothing + } + + +schoolYearDecoder : Decoder SchoolYear +schoolYearDecoder = + Decode.map5 SchoolYear + (field "id" int) + (field "name" string) + (field "start_date" string) + (field "end_date" string) + (field "is_active" bool) From 84def05c50bc17df8601baa83f0a95242071f305 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sat, 8 Nov 2025 11:27:42 +0100 Subject: [PATCH 04/13] feat: change Manual time entry to work with hours instead of start and end time according to add hours as well the logic was changed to accept ours for manual entries instead of start and end time. This allows to add negative numbers as well, which are added to working time. --- backend/database.go | 102 ++++++++++++++++-------------------------- backend/handlers.go | 20 ++++----- backend/middleware.go | 1 - frontend/src/Main.elm | 96 +++++++++++++++------------------------ 4 files changed, 84 insertions(+), 135 deletions(-) diff --git a/backend/database.go b/backend/database.go index 6897bd9..be4fcb3 100644 --- a/backend/database.go +++ b/backend/database.go @@ -307,12 +307,12 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { year, week := t.ISOWeek() - var hours float64 - if entryType == "lesson" { - hours = 1.0 - } else { - hours = calculateHoursDiff(startTime, endTime) + entry := TimeEntry{ + Type: entryType, + StartTime: startTime, + EndTime: endTime, } + hours := calculateHours(entry) key := fmt.Sprintf("%d_%d_%d", userID, year, week) if existing, exists := hoursMap[key]; exists { @@ -359,57 +359,6 @@ 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, ":") @@ -471,7 +420,6 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo var count int err := db.QueryRow(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]).Scan(&count) - if err != nil { log.Printf("Error checking entries: %v", err) return false, err @@ -489,7 +437,7 @@ func GetActiveSchoolYear(db *sql.DB) (*SchoolYear, error) { `).Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt) if err == sql.ErrNoRows { - return nil, nil // Kein aktives Schuljahr + return nil, nil } return &sy, err } @@ -560,7 +508,6 @@ func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) { WHERE date >= ? AND date <= ? ORDER BY date DESC `, schoolYear.StartDate, schoolYear.EndDate) - if err != nil { return []WeeklyHours{}, err } @@ -576,12 +523,12 @@ func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) { continue } - var hours float64 - if entryType == "lesson" { - hours = 1.0 - } else { - hours = calculateHoursDiff(startTime, endTime) + entry := TimeEntry{ + Type: entryType, + StartTime: startTime, + EndTime: endTime, } + hours := calculateHours(entry) userTotals[userID] += hours } @@ -606,3 +553,30 @@ func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) { return result, nil } + +func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error { + entry.StartTime = fmt.Sprintf("%.2f", hours) + entry.EndTime = "manual" + entry.Type = "manual" + + _, err := db.Exec(` + INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) + VALUES (?, 0, ?, ?, ?, ?) + `, entry.UserID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + + return err +} + +func calculateHours(entry TimeEntry) float64 { + if entry.Type == "lesson" { + return 1.0 + } else if entry.Type == "manual" { + hours, err := strconv.ParseFloat(entry.StartTime, 64) + if err != nil { + return 0 + } + return hours + } else { + return calculateHoursDiff(entry.StartTime, entry.EndTime) + } +} diff --git a/backend/handlers.go b/backend/handlers.go index e87b5f3..c6830fc 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -90,17 +90,15 @@ func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { isAdmin, _ := c.Get("is_admin").(bool) - if !isAdmin { return echo.NewHTTPError(http.StatusForbidden, "Only admins can create entries for others") } var req struct { - UserID int `json:"user_id"` - Date string `json:"date"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - Type string `json:"type"` + UserID int `json:"user_id"` + Date string `json:"date"` + Hours float64 `json:"hours"` + Type string `json:"type"` } if err := c.Bind(&req); err != nil { @@ -110,16 +108,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { entry := TimeEntry{ UserID: req.UserID, Date: req.Date, - StartTime: req.StartTime, - EndTime: req.EndTime, - Type: req.Type, + StartTime: "00:00", + EndTime: "00:00", + Type: "manual", } - if err := CreateTimeEntry(app.DB, &entry); err != nil { + if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) + return c.NoContent(http.StatusCreated) } func (app *App) GetUsersHandler(c echo.Context) error { diff --git a/backend/middleware.go b/backend/middleware.go index 857b9d0..4ee2231 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -192,7 +192,6 @@ func (l *LoginRateLimiter) Middleware() echo.MiddlewareFunc { func HTTPSRedirectMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - // Nur in Production aktivieren if os.Getenv("ENVIRONMENT") == "production" { if c.Request().Header.Get("X-Forwarded-Proto") != "https" { return c.Redirect(http.StatusMovedPermanently, diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 4afe315..c19a812 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -220,8 +220,7 @@ type alias YearlyHoursSummary = type alias AdminManualEntry = { selectedUserId : Maybe Int , date : String - , startTime : String - , endTime : String + , hours : String , entryType : String } @@ -293,7 +292,7 @@ init flags = , userPasswordInput = "" , isProcessing = False , mobileMenuOpen = False - , adminManualEntryForm = AdminManualEntry Nothing "" "" "" "lesson" + , adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" , schoolYears = [] , newSchoolYear = NewSchoolYear "" "" "" , activeSchoolYear = Nothing @@ -412,8 +411,7 @@ type Msg | CloseMobileMenu | SelectUserForManualEntry Int | UpdateManualEntryDate String - | UpdateManualEntryStartTime String - | UpdateManualEntryEndTime String + | UpdateManualEntryHours String | UpdateManualEntryType String | SaveAdminTimeEntry | AdminTimeEntrySaved (Result Http.Error ()) @@ -1437,19 +1435,12 @@ update msg model = in ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) - UpdateManualEntryStartTime time -> + UpdateManualEntryHours hours -> 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 ) + ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) UpdateManualEntryType entryType -> let @@ -1470,7 +1461,7 @@ update msg model = case model.token of Just token -> ( { model - | adminManualEntryForm = AdminManualEntry Nothing "" "" "" "lesson" + | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" , error = Nothing , isProcessing = False } @@ -1997,6 +1988,14 @@ calculateHours startTime endTime = if end > start then end - start + else if endTime == "manual" then + case String.toFloat startTime of + Just time -> + time + + Nothing -> + 0 + else 0 @@ -2661,23 +2660,28 @@ viewYearlyHourRow summary = viewAdminManualEntryForm : Model -> Html Msg viewAdminManualEntryForm model = div [ class "box has-background-info-light" ] - [ h3 [ class "subtitle" ] [ text "Neuer Zeiteintrag" ] + [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] + , p [ class "help mb-3" ] + [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] , div [ class "columns" ] - [ div [ class "column" ] + [ div [ class "column is-4" ] [ div [ class "field" ] [ label [ class "label" ] [ text "Mitarbeiter" ] , div [ class "control" ] [ div [ class "select is-fullwidth" ] [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] - (option [] [ text "-- Wählen --" ] - :: List.map (\user -> option [ value (String.fromInt user.id) ] [ text user.username ]) - (List.filter (\u -> not u.isAdmin) model.users) + (option [ value "" ] [ text "-- Wählen --" ] + :: List.map + (\u -> + option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] + ) + model.users ) ] ] ] ] - , div [ class "column" ] + , div [ class "column is-4" ] [ div [ class "field" ] [ label [ class "label" ] [ text "Datum" ] , div [ class "control" ] @@ -2691,47 +2695,22 @@ viewAdminManualEntryForm model = ] ] ] - ] - , div [ class "columns" ] - [ div [ class "column" ] + , div [ class "column is-4" ] [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] + [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] , div [ class "control" ] [ input [ class "input" - , type_ "time" - , value model.adminManualEntryForm.startTime - , onInput UpdateManualEntryStartTime + , type_ "number" + , step "0.5" + , placeholder "z.B. 2.5 oder -1.0" + , value model.adminManualEntryForm.hours + , onInput UpdateManualEntryHours ] [] ] - ] - ] - , 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" ] - ] - ] - ] + , p [ class "help" ] + [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] ] ] ] @@ -2743,7 +2722,7 @@ viewAdminManualEntryForm model = , disabled (case model.adminManualEntryForm.selectedUserId of Just _ -> - model.isProcessing + model.isProcessing || String.isEmpty model.adminManualEntryForm.hours Nothing -> True @@ -4012,9 +3991,8 @@ createAdminTimeEntry token entry = 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 ) + , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) + , ( "type", Encode.string "manual" ) ] , expect = Http.expectWhatever AdminTimeEntrySaved , timeout = Nothing From bb891aea0b97283de8bf45a3b0213df8f7974833 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sat, 8 Nov 2025 11:38:09 +0100 Subject: [PATCH 05/13] fix: fix styling issue with overlapping title and subtitle in weekSelection --- frontend/src/Main.elm | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index c19a812..2b420d2 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -2938,15 +2938,37 @@ viewWeekNavigation model = [ class "button is-primary" , onClick PreviousWeek ] - [ text "← Vorherige Woche" ] + [ span [ class "icon" ] + [ i [ class "fas fa-chevron-left" ] [] ] + , span [] [ text "Vorherige Woche" ] + ] ] ] - , div [ class "level-item has-text-centered" ] - [ div [] - [ p [ class "heading" ] [ text "Kalenderwoche" ] - , p [ class "title" ] + , div [ class "level-item" ] + [ div + [ style "display" "flex" + , style "flex-direction" "column" + , style "align-items" "center" + , style "gap" "0.5rem" + , style "min-width" "250px" -- Verhindert Kompression + ] + [ p + [ class "heading" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text "Kalenderwoche" ] + , p + [ class "title is-3" + , style "margin" "0" + , style "line-height" "1.2" + ] [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] - , p [ class "subtitle is-6" ] + , p + [ class "subtitle is-6" + , style "margin" "0" + , style "line-height" "1.2" + ] [ text dateRange ] ] ] @@ -2956,7 +2978,10 @@ viewWeekNavigation model = [ class "button is-primary" , onClick NextWeek ] - [ text "Nächste Woche →" ] + [ span [] [ text "Nächste Woche" ] + , span [ class "icon" ] + [ i [ class "fas fa-chevron-right" ] [] ] + ] ] ] ] From d4265cc04643062291c919627f3e14aec1083c8f Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sat, 8 Nov 2025 12:07:29 +0100 Subject: [PATCH 06/13] feat: add pdf generation --- backend/go.mod | 1 + backend/go.sum | 11 +++++ backend/handlers.go | 29 +++++++++++ backend/main.go | 1 + backend/pdf.go | 110 ++++++++++++++++++++++++++++++++++++++++++ frontend/elm.json | 8 +-- frontend/src/Main.elm | 90 +++++++++++++++++++++++++++++++++- 7 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 backend/pdf.go diff --git a/backend/go.mod b/backend/go.mod index 859ee3c..7b185e7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module school-timetracker go 1.25.3 require ( + github.com/jung-kurt/gofpdf v1.16.2 github.com/labstack/echo/v4 v4.13.4 golang.org/x/crypto v0.43.0 golang.org/x/time v0.11.0 diff --git a/backend/go.sum b/backend/go.sum index 3ab6680..fee7803 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -6,6 +8,9 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -16,10 +21,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -30,6 +39,7 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= @@ -39,6 +49,7 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/backend/handlers.go b/backend/handlers.go index c6830fc..e45c2aa 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "fmt" "net/http" "strconv" "time" @@ -494,3 +495,31 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { } return c.JSON(http.StatusOK, year) } + +func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error { + isAdmin, _ := c.Get("is_admin").(bool) + if !isAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs") + } + + schoolYear, err := GetActiveSchoolYear(app.DB) + if err != nil || schoolYear == nil { + return echo.NewHTTPError(http.StatusNotFound, "No active school year found") + } + + summary, err := GetYearlyHoursSummary(app.DB) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error()) + } + + filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name) + c.Response().Header().Set("Content-Type", "application/pdf") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + return c.Blob(http.StatusOK, "application/pdf", pdfBytes) +} diff --git a/backend/main.go b/backend/main.go index bdf47b5..78ccf01 100644 --- a/backend/main.go +++ b/backend/main.go @@ -68,6 +68,7 @@ func main() { admin.GET("/school-years", app.GetSchoolYearsHandler) admin.POST("/school-years", app.CreateSchoolYearHandler) admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler) + admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler) } e.Static("/", "./static") diff --git a/backend/pdf.go b/backend/pdf.go new file mode 100644 index 0000000..13e003e --- /dev/null +++ b/backend/pdf.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "time" + + "github.com/jung-kurt/gofpdf" +) + +func GenerateYearlySummaryPDF(schoolYear *SchoolYear, summary []WeeklyHours) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + + pdf.SetFont("Arial", "B", 20) + + title := fmt.Sprintf("Stundenjahresübersicht für Schuljahr %s", schoolYear.Name) + pdf.Cell(0, 15, title) + pdf.Ln(10) + + pdf.SetFont("Arial", "", 12) + subtitle := fmt.Sprintf("%s bis %s", schoolYear.StartDate, schoolYear.EndDate) + pdf.Cell(0, 10, subtitle) + pdf.Ln(15) + + pdf.SetFont("Arial", "B", 10) + pdf.SetFillColor(52, 152, 219) + pdf.SetTextColor(255, 255, 255) + + colWidths := []float64{60, 40, 40, 40} + headers := []string{"Mitarbeiter", "Soll (Std.)", "Ist (Std.)", "Differenz (Std.)"} + + for i, header := range headers { + pdf.CellFormat(colWidths[i], 10, header, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + + pdf.SetFont("Arial", "", 10) + pdf.SetTextColor(0, 0, 0) + fill := false + + for _, entry := range summary { + if fill { + pdf.SetFillColor(240, 240, 240) + } else { + pdf.SetFillColor(255, 255, 255) + } + + pdf.CellFormat(colWidths[0], 8, entry.Username, "1", 0, "L", true, 0, "") + + pdf.CellFormat(colWidths[1], 8, fmt.Sprintf("%.1f", entry.YearlyTarget), "1", 0, "R", true, 0, "") + + pdf.CellFormat(colWidths[2], 8, fmt.Sprintf("%.1f", entry.YearlyActual), "1", 0, "R", true, 0, "") + + diffStr := fmt.Sprintf("%.1f", entry.RemainingYearly) + if entry.RemainingYearly > 0 { + pdf.SetTextColor(220, 53, 69) + } else { + pdf.SetTextColor(40, 167, 69) + } + pdf.CellFormat(colWidths[3], 8, diffStr, "1", 0, "R", true, 0, "") + pdf.SetTextColor(0, 0, 0) + + pdf.Ln(-1) + fill = !fill + } + + pdf.Ln(5) + pdf.SetFont("Arial", "B", 10) + + totalTarget := 0.0 + totalActual := 0.0 + totalRemaining := 0.0 + + for _, entry := range summary { + totalTarget += entry.YearlyTarget + totalActual += entry.YearlyActual + totalRemaining += entry.RemainingYearly + } + + pdf.SetFillColor(52, 152, 219) + pdf.SetTextColor(255, 255, 255) + + pdf.CellFormat(colWidths[0], 10, "GESAMT", "1", 0, "L", true, 0, "") + pdf.CellFormat(colWidths[1], 10, fmt.Sprintf("%.1f", totalTarget), "1", 0, "R", true, 0, "") + pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%.1f", totalActual), "1", 0, "R", true, 0, "") + pdf.CellFormat(colWidths[3], 10, fmt.Sprintf("%.1f", totalRemaining), "1", 0, "R", true, 0, "") + + pdf.Ln(15) + pdf.SetFont("Arial", "I", 8) + pdf.SetTextColor(128, 128, 128) + pdf.Cell(0, 10, fmt.Sprintf("Erstellt am: %s", time.Now().Format("02.01.2006 15:04"))) + + var buf []byte + w := &pdfWriter{buf: &buf} + err := pdf.Output(w) + if err != nil { + return nil, err + } + + return buf, nil +} + +type pdfWriter struct { + buf *[]byte +} + +func (w *pdfWriter) Write(p []byte) (n int, err error) { + *w.buf = append(*w.buf, p...) + return len(p), nil +} diff --git a/frontend/elm.json b/frontend/elm.json index 300f393..07196ee 100644 --- a/frontend/elm.json +++ b/frontend/elm.json @@ -1,19 +1,21 @@ { "type": "application", - "source-directories": ["src"], + "source-directories": [ + "src" + ], "elm-version": "0.19.1", "dependencies": { "direct": { "elm/browser": "1.0.2", + "elm/bytes": "1.0.8", "elm/core": "1.0.5", + "elm/file": "1.0.5", "elm/html": "1.0.0", "elm/http": "2.0.0", "elm/json": "1.1.3", "elm/time": "1.0.0" }, "indirect": { - "elm/bytes": "1.0.8", - "elm/file": "1.0.5", "elm/url": "1.0.0", "elm/virtual-dom": "1.0.3" } diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 2b420d2..9f3f97d 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,7 +1,9 @@ port module Main exposing (..) import Browser +import Bytes exposing (Bytes) import Dict exposing (Dict) +import File.Download import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) @@ -430,6 +432,8 @@ type Msg | SchoolYearActivated (Result Http.Error ()) | DeleteSchoolYear Int | SchoolYearDeleted (Result Http.Error ()) + | DownloadYearlySummaryPDF + | YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes) update : Msg -> Model -> ( Model, Cmd Msg ) @@ -1631,6 +1635,29 @@ update msg model = SchoolYearDeleted (Err _) -> ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + DownloadYearlySummaryPDF -> + case model.token of + Just token -> + ( { model | isProcessing = True }, downloadYearlySummaryPDF token ) + + Nothing -> + ( model, Cmd.none ) + + YearlySummaryPDFReceived (Ok pdfBytes) -> + let + filename = + "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" + in + ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) + + YearlySummaryPDFReceived (Err _) -> + ( { model + | error = Just "Fehler beim Herunterladen der PDF" + , isProcessing = False + } + , Cmd.none + ) + -- SUBSCRIPTIONS @@ -2606,7 +2633,35 @@ viewTimeEntriesTab model = viewYearlyHoursSummary : Model -> Html Msg viewYearlyHoursSummary model = div [ class "box" ] - [ if List.isEmpty model.yearlyHoursSummary then + [ div [ class "level mb-4" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ a + [ class "button is-info" + , onClick DownloadYearlySummaryPDF + , disabled model.isProcessing + ] + [ span [ class "icon" ] + [ i [ class "fas fa-file-pdf" ] [] ] + , span [] + [ text + (if model.isProcessing then + "Wird erstellt..." + + else + "PDF exportieren" + ) + ] + ] + ] + ] + ] + , if List.isEmpty model.yearlyHoursSummary then p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] else @@ -2950,7 +3005,7 @@ viewWeekNavigation model = , style "flex-direction" "column" , style "align-items" "center" , style "gap" "0.5rem" - , style "min-width" "250px" -- Verhindert Kompression + , style "min-width" "250px" ] [ p [ class "heading" @@ -4225,3 +4280,34 @@ schoolYearDecoder = (field "start_date" string) (field "end_date" string) (field "is_active" bool) + + +downloadYearlySummaryPDF : String -> Cmd Msg +downloadYearlySummaryPDF token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/yearly-summary/pdf" + , body = Http.emptyBody + , expect = + Http.expectBytesResponse YearlySummaryPDFReceived + (\response -> + case response of + Http.GoodStatus_ _ body -> + Ok body + + Http.BadUrl_ url -> + Err (Http.BadUrl url) + + Http.Timeout_ -> + Err Http.Timeout + + Http.NetworkError_ -> + Err Http.NetworkError + + Http.BadStatus_ metadata _ -> + Err (Http.BadStatus metadata.statusCode) + ) + , timeout = Nothing + , tracker = Nothing + } From b3d4eec456dc7d575aaab5b3b767219b757f9a3e Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sat, 8 Nov 2025 14:24:30 +0100 Subject: [PATCH 07/13] feat: add README and change default working hours to 60 --- README.md | 773 ++++++++++++++++++++++++++++++++++++++++++++ backend/database.go | 2 +- backend/handlers.go | 2 +- 3 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..684b448 --- /dev/null +++ b/README.md @@ -0,0 +1,773 @@ +# Zeiterfassungssystem für pädagogische Mitarbeiter + +Eine vollständige Webanwendung zur Erfassung und Verwaltung von Flexistunden für pädagogische Mitarbeiter (PM) an Schulen. + +## 📋 Inhaltsverzeichnis + +- [Überblick](#überblick) +- [Funktionen](#funktionen) +- [Technologie-Stack](#technologie-stack) +- [Voraussetzungen](#voraussetzungen) +- [Installation](#installation) +- [Konfiguration](#konfiguration) +- [Verwendung](#verwendung) +- [API-Dokumentation](#api-dokumentation) +- [Architektur](#architektur) +- [Sicherheit](#sicherheit) +- [Backup & Wartung](#backup--wartung) +- [Fehlerbehebung](#fehlerbehebung) + +## 🎯 Überblick + +Diese Anwendung wurde entwickelt, um die Erfassung von Flexistunden (zusätzliche Arbeitsstunden) für pädagogische Mitarbeiter an Schulen zu vereinfachen. Sie ermöglicht: + +- **Mitarbeitern**: Wöchentliche Zeiterfassung anhand eines vorkonfigurierten Stundenplans +- **Administratoren**: Vollständige Verwaltung von Benutzern, Stundenplänen, Schuljahren und Zeiteinträgen + +Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Auswertungen. + +## ✨ Funktionen + +### Für Mitarbeiter + +- **Wochenbasierte Zeiterfassung**: Auswahl der gearbeiteten Zeiten aus dem Stundenplan +- **Kalenderwochen-Navigation**: Einfaches Vor- und Zurückblättern zwischen Wochen +- **Jahresübersicht**: Anzeige der geleisteten vs. Soll-Arbeitsstunden +- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile + +### Für Administratoren + +- **Benutzerverwaltung**: + - Benutzer anlegen, bearbeiten und löschen + - Jahresarbeitsstunden pro Benutzer festlegen (Standard: 1800h) + - Passwörter zurücksetzen +- **Stundenplan-Management**: + - Wochenstundenplan mit Unterrichts- und Pausenzeiten erstellen + - Unterrichtsstunden und Pausen unterscheiden + - Zeiten mit Titeln versehen (z.B. "Mathematik", "Pause") + +- **Schuljahrverwaltung**: + - Schuljahre mit Start- und Enddatum definieren + - Aktives Schuljahr setzen + - Jahresberechnungen basierend auf aktivem Schuljahr + +- **Zeiteintrags-Verwaltung**: + - Alle Zeiteinträge einsehen und bearbeiten + - Manuelle Stundeneintragungen (positiv = Abzug, negativ = Hinzurechnung) + - Einzelne Einträge korrigieren oder löschen + +- **Berichtswesen**: + - Jahresübersicht aller Mitarbeiter + - PDF-Export der Jahresübersicht + - Wochenweise Stundenauswertung + +## 🛠 Technologie-Stack + +### Frontend + +- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI +- **Bulma CSS**: Modernes CSS-Framework +- **Font Awesome**: Icons +- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung + +### Backend + +- **Go (Golang)**: Performante Backend-Sprache +- **Echo Framework**: Web-Framework für Go +- **SQLite**: Embedded SQL-Datenbank +- **JWT**: Token-basierte Authentifizierung +- **bcrypt**: Passwort-Hashing +- **gofpdf**: PDF-Generierung + +### Deployment + +- **Docker**: Containerisierung +- **Docker Compose**: Orchestrierung + +## 📦 Voraussetzungen + +### Für Docker-Deployment (empfohlen) + +- Docker (Version 20.10+) +- Docker Compose (Version 1.29+) + +### Für lokale Entwicklung + +- Go 1.21+ +- Elm 0.19 +- Node.js 16+ (für Elm-Tooling) +- SQLite3 + +## 🚀 Installation + +### Option 1: Docker Compose (Produktion) + +1. **Repository klonen** + +```bash +git clone +cd zeiterfassung +``` + +2. **Umgebungsvariablen konfigurieren** + +```bash +cp .env.example .env +nano .env +``` + +Wichtige Variablen in `.env`: + +```env +PORT=8080 +DB_PATH=/data/timetracking.db +JWT_SECRET=ihr-sicheres-geheimnis-hier-ändern +TZ=Europe/Berlin +``` + +3. **Anwendung starten** + +```bash +docker-compose up -d +``` + +4. **Anwendung aufrufen** + +``` +http://localhost:8080 +``` + +**Standard-Anmeldedaten:** + +- Benutzername: `admin` +- Passwort: `admin123` + +⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung! + +### Option 2: Lokale Entwicklung + +1. **Backend kompilieren** + +```bash +go mod download +go build -o timetracking +``` + +2. **Frontend kompilieren** + +```bash +cd static +elm make Main.elm --output=elm.js --optimize +cd .. +``` + +3. **Umgebungsvariablen setzen** + +```bash +export PORT=8080 +export DB_PATH=./timetracking.db +export JWT_SECRET=development-secret +``` + +4. **Anwendung starten** + +```bash +./timetracking +``` + +## ⚙️ Konfiguration + +### Umgebungsvariablen + +| Variable | Beschreibung | Standard | Erforderlich | +| ------------- | ------------------------------- | ------------------- | ------------ | +| `PORT` | HTTP-Server Port | `8080` | Nein | +| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | +| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | +| `TZ` | Zeitzone | `Europe/Berlin` | Nein | +| `ENVIRONMENT` | `production` für HTTPS-Redirect | - | Nein | + +### Docker-Volumes + +Das Docker-Setup erstellt ein persistentes Volume für die Datenbank: + +```yaml +volumes: + timetracking-data: + driver: local +``` + +Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert. + +## 📖 Verwendung + +### Ersteinrichtung als Administrator + +1. **Anmelden** mit den Standard-Credentials (admin/admin123) + +2. **Admin-Passwort ändern**: + - Gehe zu "Benutzer" Tab + - Klicke auf "PW Reset" beim Admin-Benutzer + - Neues sicheres Passwort eingeben + +3. **Schuljahr erstellen**: + - Wechsle zum "Schuljahre" Tab + - Erstelle ein Schuljahr (z.B. "2024/2025") + - Startdatum: 01.08.2024 + - Enddatum: 31.07.2025 + - Klicke auf "Aktivieren" + +4. **Stundenplan erstellen**: + - Wechsle zum "Stundenplan" Tab + - Füge Unterrichtsstunden hinzu: + - Wochentag auswählen + - Start- und Endzeit eingeben + - Typ: "Unterricht" oder "Pause" + - Titel vergeben (z.B. "Mathematik 1a") + +5. **Mitarbeiter anlegen**: + - Wechsle zum "Benutzer" Tab + - "Benutzer anlegen" + - Benutzername und Passwort eingeben + - Jahresarbeitsstunden festlegen (Standard: 1800h) + - Admin-Rechte nur für weitere Administratoren + +### Zeiterfassung als Mitarbeiter + +1. **Anmelden** mit persönlichen Zugangsdaten + +2. **Wochenansicht**: + - Zeigt aktuelle Kalenderwoche mit Datumsbereich + - Navigation: "Vorherige Woche" / "Nächste Woche" + +3. **Stunden erfassen**: + - Klicke auf die Zeitslots, die du gearbeitet hast + - Ausgewählte Zeiten werden grün markiert + - Klicke "Speichern" zum Übernehmen + +4. **Woche bearbeiten**: + - Falls bereits erfasst, erscheint "Bearbeiten"-Button + - Aktiviert Bearbeitungsmodus + - "Einträge löschen" entfernt alle Einträge der Woche + - Neue Auswahl treffen und "Änderungen speichern" + +5. **Jahresübersicht prüfen**: + - Unten auf der Seite: "Jahresgesamtzeit" + - Zeigt: Soll-Stunden, Geleistete Stunden, Verbleibende Stunden + - Fortschrittsbalken visualisiert den Status + +### Administrative Aufgaben + +#### Manuelle Stundeneintragung + +Für Korrekturen oder Sonderfälle: + +1. Gehe zu "Zeiteinträge" Tab +2. Sektion "Manuelle Stundeneintragung" +3. Mitarbeiter auswählen +4. Datum eingeben +5. Stunden eingeben: + - **Positive Werte** (z.B. `2.5`): Werden **abgezogen** (z.B. Krankheit, Urlaub) + - **Negative Werte** (z.B. `-3.0`): Werden **hinzugerechnet** (z.B. Nachholung, Sondereinsatz) + +#### Zeiteinträge bearbeiten + +1. Gehe zu "Zeiteinträge" Tab +2. Liste aller Einträge mit Bearbeitungsmöglichkeit +3. "Bearbeiten" klicken: + - Datum ändern + - Start-/Endzeit anpassen + - Typ ändern (Unterricht/Pause/Manuell) +4. Speichern oder Löschen + +#### PDF-Export + +1. Gehe zu "Zeiteinträge" Tab +2. Sektion "Jahresübersicht" +3. Klicke "PDF exportieren" +4. PDF enthält: + - Schuljahrname und Zeitraum + - Alle Mitarbeiter mit Soll/Ist/Differenz + - Generierungsdatum + +## 🔌 API-Dokumentation + +### Authentifizierung + +Alle geschützten Endpunkte erfordern einen JWT-Token im Header: + +``` +Authorization: Bearer +``` + +### Öffentliche Endpunkte + +#### `POST /api/login` + +Benutzer-Anmeldung + +**Request:** + +```json +{ + "username": "admin", + "password": "admin123" +} +``` + +**Response:** + +```json +{ + "token": "eyJhbGc...", + "username": "admin", + "is_admin": true +} +``` + +### Geschützte Endpunkte (Benutzer) + +#### `GET /api/schedules` + +Alle Stundenpläne abrufen + +#### `GET /api/my-time-entries` + +Eigene Zeiteinträge abrufen + +#### `POST /api/time-entries/batch` + +Mehrere Zeiteinträge auf einmal erstellen + +**Request:** + +```json +{ + "entries": [ + { + "schedule_id": 1, + "date": "2024-11-04", + "type": "lesson", + "start_time": "08:00", + "end_time": "09:00" + } + ] +} +``` + +#### `DELETE /api/my-time-entries/week?year=2024&week=45` + +Alle eigenen Einträge einer Woche löschen + +#### `GET /api/week-dates?year=2024&week=45` + +Datumsbereich einer Kalenderwoche abrufen + +#### `GET /api/week-has-entries?year=2024&week=45` + +Prüfen, ob für eine Woche bereits Einträge existieren + +#### `GET /api/yearly-hours-summary` + +Jahresübersicht für alle Benutzer + +#### `GET /api/my-info` + +Eigene Benutzerinformationen abrufen + +#### `GET /api/school-year/active` + +Aktives Schuljahr abrufen + +### Admin-Endpunkte + +#### Stundenplan-Verwaltung + +- `POST /api/admin/schedules` - Stundenplan erstellen +- `DELETE /api/admin/schedules/delete?id=1` - Stundenplan löschen + +#### Benutzerverwaltung + +- `POST /api/admin/users` - Benutzer erstellen +- `GET /api/admin/users/list` - Alle Benutzer auflisten +- `PUT /api/admin/users/:id` - Benutzer bearbeiten (Arbeitsstunden) +- `PUT /api/admin/users/:id/reset-password` - Passwort zurücksetzen +- `DELETE /api/admin/users/delete?id=2` - Benutzer löschen + +#### Zeiteintrags-Verwaltung + +- `GET /api/admin/time-entries` - Alle Zeiteinträge +- `PUT /api/admin/time-entries/:id` - Zeiteintrag bearbeiten +- `DELETE /api/admin/time-entries/:id` - Zeiteintrag löschen +- `POST /api/admin/time-entry` - Manueller Zeiteintrag + +#### Schuljahrverwaltung + +- `GET /api/admin/school-years` - Alle Schuljahre +- `POST /api/admin/school-years` - Schuljahr erstellen +- `PUT /api/admin/school-years/:id/activate` - Schuljahr aktivieren +- `DELETE /api/admin/school-years/:id` - Schuljahr löschen + +#### Berichte + +- `GET /api/admin/yearly-summary/pdf` - Jahresübersicht als PDF + +## 🏗 Architektur + +### Backend-Struktur + +``` +. +├── main.go # Einstiegspunkt, Server-Setup +├── handlers.go # HTTP-Handler für alle Endpunkte +├── middleware.go # JWT-Auth, Admin-Check, Rate-Limiting +├── database.go # Datenbanklogik und Queries +├── models.go # Datenstrukturen +├── pdf.go # PDF-Generierung +├── docker-compose.yml # Docker-Orchestrierung +└── Dockerfile # Container-Image +``` + +### Frontend-Struktur + +``` +static/ +├── Main.elm # Elm-Hauptanwendung +│ ├── Model # Anwendungszustand +│ ├── Update # Zustandsänderungen +│ ├── View # UI-Rendering +│ └── Subscriptions # Event-Handling +├── index.html # HTML-Wrapper +└── elm.js # Kompilierte Elm-Anwendung +``` + +### Datenbank-Schema + +#### Tabelle: `users` + +```sql +id INTEGER PRIMARY KEY +username TEXT UNIQUE NOT NULL +password TEXT NOT NULL (bcrypt-hashed) +is_admin BOOLEAN DEFAULT 0 +yearly_hours REAL DEFAULT 1800.0 +created_at DATETIME +``` + +#### Tabelle: `schedules` + +```sql +id INTEGER PRIMARY KEY +day_of_week INTEGER (0=Montag, 4=Freitag) +start_time TEXT (HH:MM) +end_time TEXT (HH:MM) +type TEXT (lesson/break) +title TEXT +created_at DATETIME +``` + +#### Tabelle: `time_entries` + +```sql +id INTEGER PRIMARY KEY +user_id INTEGER (FK -> users) +schedule_id INTEGER (FK -> schedules) +date TEXT (YYYY-MM-DD) +type TEXT (lesson/break/manual) +start_time TEXT +end_time TEXT +created_at DATETIME +``` + +#### Tabelle: `school_years` + +```sql +id INTEGER PRIMARY KEY +name TEXT UNIQUE +start_date DATE +end_date DATE +is_active BOOLEAN DEFAULT 0 +created_at DATETIME +``` + +#### Tabelle: `audit_logs` + +```sql +id INTEGER PRIMARY KEY +user_id INTEGER +action TEXT +details TEXT +created_at DATETIME +``` + +### Stundenberechnung + +Die Anwendung berechnet Arbeitsstunden nach folgenden Regeln: + +1. **Unterrichtsstunden** (`type: "lesson"`): **1,0 Stunde** (fest) +2. **Pausen** (`type: "break"`): Differenz zwischen End- und Startzeit +3. **Manuelle Einträge** (`type: "manual"`): + - Positive Werte werden abgezogen + - Negative Werte werden hinzugerechnet + +**Beispiel:** + +``` +Unterricht 08:00-09:00 → 1,0h +Pause 09:00-09:15 → 0,25h +Manueller Eintrag: 2.5 → -2,5h (Abzug) +Manueller Eintrag: -1.0 → +1,0h (Zuschlag) +``` + +### ISO-Kalenderwochen + +Die Anwendung verwendet ISO 8601 Kalenderwochen: + +- Woche beginnt am Montag +- Erste Woche des Jahres enthält den 4. Januar +- 52 oder 53 Wochen pro Jahr + +## 🔒 Sicherheit + +### Implementierte Sicherheitsmaßnahmen + +1. **Passwort-Hashing**: bcrypt mit Default-Cost (10 Runden) +2. **JWT-Authentifizierung**: HMAC-SHA256 mit 2h Ablaufzeit +3. **CORS-Protection**: Konfigurierbare Origins +4. **Rate Limiting**: 5 Login-Versuche pro Minute pro IP +5. **SQL-Injection-Schutz**: Prepared Statements +6. **Admin-Schutz**: Admin-Benutzer (ID=1) kann nicht gelöscht werden +7. **Input-Validierung**: Server- und clientseitig + +### Best Practices + +1. **JWT_SECRET ändern**: Verwenden Sie einen starken, zufälligen String (64+ Zeichen) + + ```bash + openssl rand -base64 64 + ``` + +2. **HTTPS verwenden**: In Produktion immer TLS/SSL aktivieren + + ```env + ENVIRONMENT=production + ``` + +3. **Regelmäßige Backups**: Sichern Sie die Datenbank täglich + +4. **Starke Passwörter**: Mindestens 12 Zeichen, Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen + +5. **Updates**: Halten Sie Dependencies aktuell + ```bash + go get -u ./... + docker-compose pull + ``` + +## 💾 Backup & Wartung + +### Datenbank-Backup + +**Docker-Setup:** + +```bash +# Backup erstellen +docker exec school-timetracking sqlite3 /data/timetracking.db ".backup '/data/backup-$(date +%Y%m%d).db'" +docker cp school-timetracking:/data/backup-$(date +%Y%m%d).db ./backups/ + +# Backup wiederherstellen +docker cp ./backups/backup-20241108.db school-timetracking:/data/timetracking.db +docker-compose restart +``` + +**Lokales Setup:** + +```bash +# Backup +sqlite3 timetracking.db ".backup 'backup-$(date +%Y%m%d).db'" + +# Wiederherstellen +cp backup-20241108.db timetracking.db +``` + +### Automatisches Backup (Cron) + +Erstellen Sie ein Backup-Script `backup.sh`: + +```bash +#!/bin/bash +BACKUP_DIR="/path/to/backups" +DATE=$(date +%Y%m%d-%H%M%S) + +docker exec school-timetracking sqlite3 /data/timetracking.db ".backup '/data/backup-$DATE.db'" +docker cp school-timetracking:/data/backup-$DATE.db $BACKUP_DIR/ + +# Alte Backups löschen (älter als 30 Tage) +find $BACKUP_DIR -name "backup-*.db" -mtime +30 -delete +``` + +Crontab-Eintrag (täglich um 3 Uhr): + +``` +0 3 * * * /path/to/backup.sh +``` + +### Log-Rotation + +Docker Compose Log-Größe begrenzen: + +```yaml +services: + timetracking: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +### Updates durchführen + +```bash +# Repository aktualisieren +git pull + +# Neue Images bauen +docker-compose build + +# Container neu starten +docker-compose down +docker-compose up -d + +# Logs prüfen +docker-compose logs -f +``` + +## 🐛 Fehlerbehebung + +### Anwendung startet nicht + +**Problem**: Container startet nicht + +```bash +docker-compose logs timetracking +``` + +**Häufige Ursachen:** + +- `JWT_SECRET` nicht gesetzt → Setzen Sie die Variable in `.env` +- Port 8080 bereits belegt → Ändern Sie `PORT` in `.env` +- Datenbank-Berechtigungen → Prüfen Sie Volume-Permissions + +### Login funktioniert nicht + +**Problem**: "Invalid credentials" trotz korrekten Passworts + +**Lösung:** + +```bash +# Admin-Passwort zurücksetzen +docker exec -it school-timetracking /bin/sh +sqlite3 /data/timetracking.db + +# In SQLite: +UPDATE users SET password = '$2a$10$...' WHERE username = 'admin'; +.exit +``` + +Generieren Sie einen neuen bcrypt-Hash: + +```bash +# Mit Go +echo -n 'neues-passwort' | go run -e 'import "golang.org/x/crypto/bcrypt"; pass, _ := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost); fmt.Println(string(pass))' +``` + +### Zeiteinträge werden nicht gespeichert + +**Problem**: Fehler beim Speichern von Zeiteinträgen + +**Prüfen:** + +1. Browser-Konsole auf JavaScript-Fehler prüfen +2. Backend-Logs prüfen: `docker-compose logs -f` +3. JWT-Token gültig? → Neu anmelden +4. Datenbank-Speicherplatz verfügbar? + +### PDF-Export schlägt fehl + +**Problem**: "Failed to generate PDF" + +**Lösung:** + +```bash +# Container-Logs prüfen +docker-compose logs timetracking | grep -i pdf + +# Häufig: Fehlende Schriftarten +# → Rebuilden Sie das Image +docker-compose build --no-cache +``` + +### Responsive Layout funktioniert nicht + +**Problem**: Mobile Ansicht nicht korrekt + +**Lösung:** + +- Browser-Cache leeren +- Elm neu kompilieren: + ```bash + cd static + elm make Main.elm --output=elm.js --optimize + ``` + +### Datenbankfehler nach Update + +**Problem**: "Database schema error" + +**Lösung:** + +```bash +# Backup erstellen +docker cp school-timetracking:/data/timetracking.db ./backup-pre-migration.db + +# Migration manuell durchführen +docker exec -it school-timetracking sqlite3 /data/timetracking.db + +# Fehlende Spalten hinzufügen (Beispiel) +ALTER TABLE users ADD COLUMN yearly_hours REAL DEFAULT 1800.0; +.exit +``` + +## 📝 Häufig gestellte Fragen (FAQ) + +**Q: Wie ändere ich die Standard-Arbeitsstunden?** +A: Als Admin unter "Benutzer" → Benutzer auswählen → "Arbeitszeit" klicken → Neue Stundenzahl eingeben. + +**Q: Können Mitarbeiter vergangene Wochen bearbeiten?** +A: Ja, über die Wochen-Navigation können alle Wochen bearbeitet werden (sofern im aktuellen Schuljahr). + +**Q: Wie funktioniert die Schuljahr-Berechnung?** +A: Das System berechnet Stunden nur für Einträge innerhalb des aktiven Schuljahres (Start- bis Enddatum). + +**Q: Was passiert bei 53-Wochen-Jahren?** +A: Das System unterstützt ISO-Kalenderwochen inklusive Woche 53 automatisch. + +**Q: Kann ich mehrere Schuljahre parallel nutzen?** +A: Es kann immer nur ein Schuljahr aktiv sein. Berechnungen basieren auf diesem Zeitraum. + +**Q: Wie funktionieren negative Stunden?** +A: Negative Werte bei manuellen Einträgen werden **zum** Stundenkonto **hinzugerechnet** (für Zusatzleistungen). + +## 📄 Lizenz + +Todo + +## 👥 Kontakt & Support + +Todo + +--- + +**Version**: 1.0.0 +**Letztes Update**: November 2024 +**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter diff --git a/backend/database.go b/backend/database.go index be4fcb3..bd15b02 100644 --- a/backend/database.go +++ b/backend/database.go @@ -35,7 +35,7 @@ func createTables(db *sql.DB) { 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 + yearly_hours REAL NOT NULL DEFAULT 60.0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS schedules ( diff --git a/backend/handlers.go b/backend/handlers.go index e45c2aa..e9567d6 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -438,7 +438,7 @@ func (app *App) CreateUserHandler(c echo.Context) error { } if req.YearlyHours == 0 { - req.YearlyHours = 1800.0 + req.YearlyHours = 60.0 } if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil { From 95057c1b8d99497951127d69f69acc24e277b15c Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sat, 8 Nov 2025 14:27:52 +0100 Subject: [PATCH 08/13] fix: fix missing fetch and wrong version in README --- README.md | 12 ++++++------ frontend/src/Main.elm | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 684b448..d35de51 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au - **Benutzerverwaltung**: - Benutzer anlegen, bearbeiten und löschen - - Jahresarbeitsstunden pro Benutzer festlegen (Standard: 1800h) + - Jahresarbeitsstunden pro Benutzer festlegen (Standard: 60h) - Passwörter zurücksetzen - **Stundenplan-Management**: - Wochenstundenplan mit Unterrichts- und Pausenzeiten erstellen @@ -229,7 +229,7 @@ Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert. - Wechsle zum "Benutzer" Tab - "Benutzer anlegen" - Benutzername und Passwort eingeben - - Jahresarbeitsstunden festlegen (Standard: 1800h) + - Jahresarbeitsstunden festlegen (Standard: 60h) - Admin-Rechte nur für weitere Administratoren ### Zeiterfassung als Mitarbeiter @@ -450,7 +450,7 @@ id INTEGER PRIMARY KEY username TEXT UNIQUE NOT NULL password TEXT NOT NULL (bcrypt-hashed) is_admin BOOLEAN DEFAULT 0 -yearly_hours REAL DEFAULT 1800.0 +yearly_hours REAL DEFAULT 60.0 created_at DATETIME ``` @@ -734,7 +734,7 @@ docker cp school-timetracking:/data/timetracking.db ./backup-pre-migration.db docker exec -it school-timetracking sqlite3 /data/timetracking.db # Fehlende Spalten hinzufügen (Beispiel) -ALTER TABLE users ADD COLUMN yearly_hours REAL DEFAULT 1800.0; +ALTER TABLE users ADD COLUMN yearly_hours REAL DEFAULT 60.0; .exit ``` @@ -768,6 +768,6 @@ Todo --- -**Version**: 1.0.0 -**Letztes Update**: November 2024 +**Version**: 1.1.0 +**Letztes Update**: November 2025 **Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 9f3f97d..db45a90 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -2542,7 +2542,7 @@ viewUserYearlyTotal model = List.filter (\u -> not u.isAdmin) model.users |> List.head |> Maybe.map .yearlyWorkHours - |> Maybe.withDefault 1800 + |> Maybe.withDefault 60 remaining = userTarget - yearlyTotal From 3ac1947106a30df3e5e3b1c12e9b5acfa5d5ce2e Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 12:13:47 +0100 Subject: [PATCH 09/13] feat: improve app security and error handling Improve overall app security by: - using dynamic statements for all sql querries - introducing environment variables for initial admin password - introducing enironment variable for cors address - improving error handling --- README.md | 22 +- backend/errors.go | 205 +++++++++++++++++ backend/go.mod | 2 + backend/go.sum | 4 + backend/handlers.go | 341 +++++++++++++++++++++-------- backend/main.go | 15 +- backend/middleware.go | 117 +++------- backend/models.go | 6 +- backend/static/index.html | 311 ++++++++++++++++++++------ frontend/public/index.html | 326 ++++++++++++++++++++------- frontend/src/Main.elm | 437 ++++++++++++++++++++++++++----------- 11 files changed, 1333 insertions(+), 453 deletions(-) create mode 100644 backend/errors.go diff --git a/README.md b/README.md index d35de51..4922c95 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ http://localhost:8080 **Standard-Anmeldedaten:** - Benutzername: `admin` -- Passwort: `admin123` +- Passwort: Das in `docker-compose.yml` unter `INITIAL_ADMIN_PASSWORD` festgelegte Passwort. ⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung! @@ -179,13 +179,15 @@ export JWT_SECRET=development-secret ### Umgebungsvariablen -| Variable | Beschreibung | Standard | Erforderlich | -| ------------- | ------------------------------- | ------------------- | ------------ | -| `PORT` | HTTP-Server Port | `8080` | Nein | -| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | -| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | -| `TZ` | Zeitzone | `Europe/Berlin` | Nein | -| `ENVIRONMENT` | `production` für HTTPS-Redirect | - | Nein | +| Variable | Beschreibung | Standard | Erforderlich | +| ------------------------ | ------------------------------------------------ | --------------------------------- | ------------ | +| `PORT` | HTTP-Server Port | `8080` | Nein | +| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | +| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | +| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | +| `TZ` | Zeitzone | `Europe/Berlin` | Nein | +| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | +| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | ### Docker-Volumes @@ -203,7 +205,7 @@ Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert. ### Ersteinrichtung als Administrator -1. **Anmelden** mit den Standard-Credentials (admin/admin123) +1. **Anmelden** mit den Standard-Credentials (admin/das initiale Passwort aus der Konfiguration) 2. **Admin-Passwort ändern**: - Gehe zu "Benutzer" Tab @@ -311,7 +313,7 @@ Benutzer-Anmeldung ```json { "username": "admin", - "password": "admin123" + "password": "" } ``` diff --git a/backend/errors.go b/backend/errors.go new file mode 100644 index 0000000..7ee17bd --- /dev/null +++ b/backend/errors.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "net/http" +) + +type ErrorCode string + +const ( + // Authentifizierung + ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS" + ErrUnauthorized ErrorCode = "UNAUTHORIZED" + ErrTokenExpired ErrorCode = "TOKEN_EXPIRED" + ErrAccessDenied ErrorCode = "ACCESS_DENIED" + + // Validierung + ErrInvalidInput ErrorCode = "INVALID_INPUT" + ErrMissingField ErrorCode = "MISSING_FIELD" + ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT" + ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT" + + // Ressourcen + ErrNotFound ErrorCode = "NOT_FOUND" + ErrAlreadyExists ErrorCode = "ALREADY_EXISTS" + ErrCannotDelete ErrorCode = "CANNOT_DELETE" + ErrProtectedUser ErrorCode = "PROTECTED_USER" + ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR" + + // Datenbank + ErrDatabase ErrorCode = "DATABASE_ERROR" + ErrTransaction ErrorCode = "TRANSACTION_ERROR" + ErrQueryFailed ErrorCode = "QUERY_FAILED" + + // Server + ErrInternal ErrorCode = "INTERNAL_ERROR" + ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE" +) + +type AppError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + UserMsg string `json:"user_message"` + HTTPStatus int `json:"-"` + Internal error `json:"-"` +} + +func (e *AppError) Error() string { + if e.Internal != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError { + return &AppError{ + Code: code, + Message: message, + UserMsg: userMsg, + HTTPStatus: httpStatus, + Internal: internal, + } +} + +func ErrInvalidCredentialsMsg() *AppError { + return NewAppError( + ErrInvalidCredentials, + "Invalid username or password", + "Benutzername oder Passwort ungültig", + http.StatusUnauthorized, + nil, + ) +} + +func ErrUnauthorizedMsg() *AppError { + return NewAppError( + ErrUnauthorized, + "Unauthorized access", + "Keine Berechtigung für diese Aktion", + http.StatusUnauthorized, + nil, + ) +} + +func ErrTokenExpiredMsg() *AppError { + return NewAppError( + ErrTokenExpired, + "Token has expired", + "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an", + http.StatusUnauthorized, + nil, + ) +} + +func ErrAccessDeniedMsg() *AppError { + return NewAppError( + ErrAccessDenied, + "Access denied - admin privileges required", + "Zugriff verweigert. Administrator-Rechte erforderlich", + http.StatusForbidden, + nil, + ) +} + +func ErrInvalidInputMsg(field string) *AppError { + return NewAppError( + ErrInvalidInput, + fmt.Sprintf("Invalid input for field: %s", field), + fmt.Sprintf("Ungültige Eingabe im Feld: %s", field), + http.StatusBadRequest, + nil, + ) +} + +func ErrMissingFieldMsg(field string) *AppError { + return NewAppError( + ErrMissingField, + fmt.Sprintf("Required field missing: %s", field), + fmt.Sprintf("Pflichtfeld fehlt: %s", field), + http.StatusBadRequest, + nil, + ) +} + +func ErrNotFoundMsg(resource string) *AppError { + return NewAppError( + ErrNotFound, + fmt.Sprintf("%s not found", resource), + fmt.Sprintf("%s nicht gefunden", resource), + http.StatusNotFound, + nil, + ) +} + +func ErrAlreadyExistsMsg(resource string) *AppError { + return NewAppError( + ErrAlreadyExists, + fmt.Sprintf("%s already exists", resource), + fmt.Sprintf("%s existiert bereits", resource), + http.StatusConflict, + nil, + ) +} + +func ErrCannotDeleteMsg(resource, reason string) *AppError { + return NewAppError( + ErrCannotDelete, + fmt.Sprintf("Cannot delete %s: %s", resource, reason), + fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason), + http.StatusBadRequest, + nil, + ) +} + +func ErrProtectedUserMsg() *AppError { + return NewAppError( + ErrProtectedUser, + "Cannot modify protected admin user", + "Der Admin-Benutzer ist geschützt und kann nicht geändert werden", + http.StatusForbidden, + nil, + ) +} + +func ErrNoActiveSchoolYearMsg() *AppError { + return NewAppError( + ErrNoActiveSchool, + "No active school year configured", + "Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr", + http.StatusNotFound, + nil, + ) +} + +func ErrDatabaseMsg(internal error) *AppError { + return NewAppError( + ErrDatabase, + "Database operation failed", + "Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut", + http.StatusInternalServerError, + internal, + ) +} + +func ErrInternalMsg(internal error) *AppError { + return NewAppError( + ErrInternal, + "Internal server error", + "Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut", + http.StatusInternalServerError, + internal, + ) +} + +type ErrorResponse struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` +} + +func (e *AppError) ToResponse() ErrorResponse { + return ErrorResponse{ + Code: e.Code, + Message: e.UserMsg, + } +} diff --git a/backend/go.mod b/backend/go.mod index 7b185e7..2a1d344 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,7 +12,9 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/labstack/echo-jwt/v4 v4.3.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/backend/go.sum b/backend/go.sum index fee7803..6c63134 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -11,6 +13,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= +github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= diff --git a/backend/handlers.go b/backend/handlers.go index e9567d6..067a4ea 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -3,10 +3,13 @@ package main import ( "database/sql" "fmt" + "log" "net/http" "strconv" + "strings" "time" + "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" ) @@ -15,24 +18,60 @@ type App struct { DB *sql.DB } +func HandleError(c echo.Context, err *AppError) error { + log.Printf("[%s] %s", err.Code, err.Error()) + + return c.JSON(err.HTTPStatus, err.ToResponse()) +} + +func getClaims(c echo.Context) (*Claims, error) { + user, ok := c.Get("user").(*jwt.Token) + if !ok { + return nil, fmt.Errorf("JWT token missing or invalid") + } + + claims, ok := user.Claims.(*Claims) + if !ok { + return nil, fmt.Errorf("failed to parse JWT claims") + } + + return claims, nil +} + +func isDuplicateError(err error) bool { + return err != nil && (err.Error() == "UNIQUE constraint failed" || + strings.Contains(err.Error(), "UNIQUE") || + strings.Contains(err.Error(), "duplicate")) +} + func (app *App) LoginHandler(c echo.Context) error { var req LoginRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Login-Daten")) + } + + if req.Username == "" { + return HandleError(c, ErrMissingFieldMsg("Benutzername")) + } + if req.Password == "" { + return HandleError(c, ErrMissingFieldMsg("Passwort")) } user, err := GetUserByUsername(app.DB, req.Username) if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") + if err == sql.ErrNoRows { + return HandleError(c, ErrInvalidCredentialsMsg()) + } + return HandleError(c, ErrDatabaseMsg(err)) } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") + return HandleError(c, ErrInvalidCredentialsMsg()) } token, err := createToken(user.ID, user.Username, user.IsAdmin) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") + return HandleError(c, ErrInternalMsg(err)) } response := LoginResponse{ @@ -47,7 +86,7 @@ func (app *App) LoginHandler(c echo.Context) error { func (app *App) GetSchedulesHandler(c echo.Context) error { schedules, err := GetAllSchedules(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, schedules) } @@ -55,24 +94,40 @@ func (app *App) GetSchedulesHandler(c echo.Context) error { func (app *App) CreateScheduleHandler(c echo.Context) error { var schedule Schedule if err := c.Bind(&schedule); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten")) + } + + if schedule.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if schedule.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) + } + if schedule.Title == "" { + return HandleError(c, ErrMissingFieldMsg("Titel")) } if err := CreateSchedule(app.DB, &schedule); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"}) } func (app *App) DeleteScheduleHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid id") + return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID")) } if err := DeleteSchedule(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Stundenplan")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) @@ -81,7 +136,7 @@ func (app *App) DeleteScheduleHandler(c echo.Context) error { func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { hours, err := GetYearlyHoursSummary(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if hours == nil { hours = []WeeklyHours{} @@ -90,11 +145,6 @@ func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { } func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { - isAdmin, _ := c.Get("is_admin").(bool) - if !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Only admins can create entries for others") - } - var req struct { UserID int `json:"user_id"` Date string `json:"date"` @@ -103,7 +153,17 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { } if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if req.UserID == 0 { + return HandleError(c, ErrMissingFieldMsg("Benutzer")) + } + if req.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if req.Hours == 0 { + return HandleError(c, ErrMissingFieldMsg("Stunden")) } entry := TimeEntry{ @@ -115,16 +175,16 @@ func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { } if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } - return c.NoContent(http.StatusCreated) + return c.NoContent(http.StatusNoContent) } func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if users == nil { users = []User{} @@ -135,39 +195,62 @@ func (app *App) GetUsersHandler(c echo.Context) error { func (app *App) DeleteUserHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid id") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + } + + if id == 1 { + return HandleError(c, ErrProtectedUserMsg()) } if err := DeleteUser(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) } func (app *App) CreateTimeEntryHandler(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } var entry TimeEntry if err := c.Bind(&entry); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) } - entry.UserID = userID + if entry.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if entry.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if entry.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) + } + + entry.UserID = claims.UserID if err := CreateTimeEntry(app.DB, &entry); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"}) } func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { - userID := c.Get("user_id").(int) - - entries, err := GetTimeEntriesByUser(app.DB, userID) + claims, err := getClaims(c) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrUnauthorizedMsg()) + } + + entries, err := GetTimeEntriesByUser(app.DB, claims.UserID) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) } if entries == nil { entries = []TimeEntry{} @@ -179,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeekDates(c echo.Context) error { year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) + } + + if week < 1 || week > 53 { + return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)")) } dates := calculateWeekDates(year, week) @@ -192,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error { } func (app *App) CheckWeekHasEntries(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) } - hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week) + hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries}) @@ -215,7 +305,7 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error { func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { entries, err := GetAllTimeEntries(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if entries == nil { entries = []TimeEntry{} @@ -226,7 +316,7 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeeklyHoursHandler(c echo.Context) error { hours, err := GetWeeklyHours(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if hours == nil { hours = []WeeklyHours{} @@ -235,20 +325,23 @@ func (app *App) GetWeeklyHoursHandler(c echo.Context) error { } func (app *App) DeleteWeekEntries(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) } - if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) @@ -310,52 +403,70 @@ type BatchTimeEntryRequest struct { } func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } var req BatchTimeEntryRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if len(req.Entries) == 0 { + return HandleError(c, ErrMissingFieldMsg("Zeiteinträge")) } tx, err := app.DB.Begin() if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") + return HandleError(c, ErrDatabaseMsg(err)) } defer tx.Rollback() stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)") if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") + return HandleError(c, ErrDatabaseMsg(err)) } defer stmt.Close() for _, entry := range req.Entries { - _, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + _, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "insert error") + return HandleError(c, ErrDatabaseMsg(err)) } } if err := tx.Commit(); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "commit error") + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"}) } func (app *App) UpdateUserHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + } + + if userID == 1 { + return HandleError(c, ErrProtectedUserMsg()) } var req UpdateUserRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) + } + + if req.YearlyHours <= 0 { + return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)")) } if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -364,21 +475,28 @@ func (app *App) UpdateUserHandler(c echo.Context) error { func (app *App) ResetPasswordHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) } var req ResetPasswordRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Passwort-Daten")) + } + + if len(req.NewPassword) < 6 { + return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + return HandleError(c, ErrInternalMsg(err)) } if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -387,16 +505,29 @@ func (app *App) ResetPasswordHandler(c echo.Context) error { func (app *App) UpdateTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) } var req UpdateTimeEntryRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if req.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if req.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if req.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) } if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -405,22 +536,31 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error { func (app *App) DeleteTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) } if err := DeleteTimeEntry(app.DB, entryID); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) } func (app *App) GetMyInfoHandler(c echo.Context) error { - userID := c.Get("user_id").(int) - - user, err := GetUserByID(app.DB, userID) + claims, err := getClaims(c) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrUnauthorizedMsg()) + } + + user, err := GetUserByID(app.DB, claims.UserID) + if err != nil { + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, user) @@ -429,12 +569,22 @@ func (app *App) GetMyInfoHandler(c echo.Context) error { func (app *App) CreateUserHandler(c echo.Context) error { var req CreateUserRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) + } + + if req.Username == "" { + return HandleError(c, ErrMissingFieldMsg("Benutzername")) + } + if req.Password == "" { + return HandleError(c, ErrMissingFieldMsg("Passwort")) + } + if len(req.Password) < 6 { + return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + return HandleError(c, ErrInternalMsg(err)) } if req.YearlyHours == 0 { @@ -442,7 +592,10 @@ func (app *App) CreateUserHandler(c echo.Context) error { } if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Benutzername")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusCreated) @@ -451,7 +604,7 @@ func (app *App) CreateUserHandler(c echo.Context) error { func (app *App) GetSchoolYearsHandler(c echo.Context) error { years, err := GetAllSchoolYears(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if years == nil { years = []SchoolYear{} @@ -462,11 +615,24 @@ func (app *App) GetSchoolYearsHandler(c echo.Context) error { func (app *App) CreateSchoolYearHandler(c echo.Context) error { var req CreateSchoolYearRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Schuljahr-Daten")) + } + + if req.Name == "" { + return HandleError(c, ErrMissingFieldMsg("Name")) + } + if req.StartDate == "" { + return HandleError(c, ErrMissingFieldMsg("Startdatum")) + } + if req.EndDate == "" { + return HandleError(c, ErrMissingFieldMsg("Enddatum")) } if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Schuljahr")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusCreated) @@ -475,11 +641,14 @@ func (app *App) CreateSchoolYearHandler(c echo.Context) error { func (app *App) SetActiveSchoolYearHandler(c echo.Context) error { id, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID") + return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID")) } if err := SetActiveSchoolYear(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Schuljahr")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusNoContent) @@ -488,7 +657,7 @@ func (app *App) SetActiveSchoolYearHandler(c echo.Context) error { func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { year, err := GetActiveSchoolYear(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } if year == nil { return c.JSON(http.StatusOK, map[string]any{"active": false}) @@ -497,24 +666,22 @@ func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { } func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error { - isAdmin, _ := c.Get("is_admin").(bool) - if !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Only admins can generate PDFs") - } - schoolYear, err := GetActiveSchoolYear(app.DB) - if err != nil || schoolYear == nil { - return echo.NewHTTPError(http.StatusNotFound, "No active school year found") + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if schoolYear == nil { + return HandleError(c, ErrNoActiveSchoolYearMsg()) } summary, err := GetYearlyHoursSummary(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate PDF: "+err.Error()) + return HandleError(c, ErrInternalMsg(err)) } filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name) diff --git a/backend/main.go b/backend/main.go index 78ccf01..7e1903e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "os" + "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -24,8 +25,20 @@ func main() { e.Use(middleware.Logger()) e.Use(middleware.Recover()) + + // CORS Configuration + allowOrigins := []string{"*"} // Default for development + if os.Getenv("ENVIRONMENT") == "production" { + origins := os.Getenv("CORS_ALLOWED_ORIGINS") + if origins != "" { + allowOrigins = strings.Split(origins, ",") + } else { + log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.") + } + } + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, + AllowOrigins: allowOrigins, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) diff --git a/backend/middleware.go b/backend/middleware.go index 4ee2231..78d693c 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,17 +1,13 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" "net/http" "os" - "strings" "sync" "time" + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "golang.org/x/time/rate" @@ -28,104 +24,43 @@ func init() { } func createToken(userID int, username string, isAdmin bool) (string, error) { - claims := Claims{ + claims := &Claims{ UserID: userID, Username: username, IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), + }, } - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - - claimsWithExp := map[string]any{ - "user_id": claims.UserID, - "username": claims.Username, - "is_admin": claims.IsAdmin, - "exp": time.Now().Add(2 * time.Hour).Unix(), - } - - payload, _ := json.Marshal(claimsWithExp) - payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) - - message := header + "." + payloadEncoded - - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - return message + "." + signature, nil -} - -func verifyToken(tokenString string) (*Claims, error) { - parts := strings.Split(tokenString, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid token format") - } - - message := parts[0] + "." + parts[1] - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - if parts[2] != expectedSignature { - return nil, fmt.Errorf("invalid signature") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } - - var claimsMap map[string]any - if err := json.Unmarshal(payload, &claimsMap); err != nil { - return nil, err - } - - if exp, ok := claimsMap["exp"].(float64); ok { - if time.Now().Unix() > int64(exp) { - return nil, fmt.Errorf("token expired") - } - } - - claims := &Claims{ - UserID: int(claimsMap["user_id"].(float64)), - Username: claimsMap["username"].(string), - IsAdmin: claimsMap["is_admin"].(bool), - } - - return claims, nil + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) } func JWTMiddleware() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - authHeader := c.Request().Header.Get("Authorization") - if authHeader == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - claims, err := verifyToken(tokenString) - if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") - } - - c.Set("user_id", claims.UserID) - c.Set("username", claims.Username) - c.Set("is_admin", claims.IsAdmin) - - c.Logger().Infof("Authenticated user: ID=%d, Username=%s", claims.UserID, claims.Username) - - return next(c) - } - } + return echojwt.WithConfig(echojwt.Config{ + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return new(Claims) + }, + SigningKey: jwtSecret, + }) } func AdminMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - isAdmin, ok := c.Get("is_admin").(bool) - if !ok || !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "Access denied") + user, ok := c.Get("user").(*jwt.Token) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid") + } + + claims, ok := user.Claims.(*Claims) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims") + } + + if !claims.IsAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required") } return next(c) } diff --git a/backend/models.go b/backend/models.go index 1348146..8429bb6 100644 --- a/backend/models.go +++ b/backend/models.go @@ -1,6 +1,9 @@ package main -import "time" +import ( + "github.com/golang-jwt/jwt/v5" + "time" +) type TimeEntry struct { ID int `json:"id"` @@ -96,4 +99,5 @@ type Claims struct { UserID int `json:"user_id"` Username string `json:"username"` IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims } diff --git a/backend/static/index.html b/backend/static/index.html index 6be48fa..12ae1c0 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1,149 +1,338 @@ - + + - - - + + + Zeiterfassung - - - - - + + + + + +
- + + diff --git a/frontend/public/index.html b/frontend/public/index.html index 71337d4..12ae1c0 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,164 +1,338 @@ - + + - - - + + + Zeiterfassung - - - - - - - + + + + + +
- + + diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index db45a90..710b286 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -10,6 +10,7 @@ import Html.Events exposing (..) import Http import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) import Json.Encode as Encode +import Process import Task import Time @@ -99,6 +100,23 @@ type alias Model = , newSchoolYear : NewSchoolYear , activeSchoolYear : Maybe SchoolYear , editingSchoolYearId : Maybe Int + , toasts : List Toast + , nextToastId : Int + } + + +type ToastType + = ErrorToast + | SuccessToast + | InfoToast + | WarningToast + + +type alias Toast = + { id : Int + , message : String + , toastType : ToastType + , dismissible : Bool } @@ -299,6 +317,8 @@ init flags = , newSchoolYear = NewSchoolYear "" "" "" , activeSchoolYear = Nothing , editingSchoolYearId = Nothing + , toasts = [] + , nextToastId = 0 } cmd = @@ -309,7 +329,11 @@ init flags = , fetchSchedules (Just token) , fetchYearlyHoursSummary token , if flags.isAdmin then - fetchSchoolYears token + Cmd.batch + [ fetchSchoolYears token + , fetchUsers token + , fetchAllTimeEntries token + ] else fetchMyInfo token @@ -434,6 +458,9 @@ type Msg | SchoolYearDeleted (Result Http.Error ()) | DownloadYearlySummaryPDF | YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes) + | ShowToast String ToastType + | DismissToast Int + | AutoDismissToast Int update : Msg -> Model -> ( Model, Cmd Msg ) @@ -487,6 +514,7 @@ update msg model = , Cmd.batch [ saveToken tokenData , fetchSchedules (Just result.token) + , Task.perform (\_ -> ShowToast ("Willkommen, " ++ result.username ++ "!") SuccessToast) (Task.succeed ()) , if not result.isAdmin then Cmd.batch [ fetchMyTimeEntries result.token @@ -506,8 +534,25 @@ update msg model = ] ) - LoginResponse (Err _) -> - ( { model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none ) + LoginResponse (Err err) -> + let + errorMsg = + case err of + Http.BadStatus 401 -> + "Benutzername oder Passwort ungültig" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Anmeldung fehlgeschlagen" + in + ( { model | isProcessing = False } + , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) + ) Logout -> ( { model @@ -527,8 +572,8 @@ update msg model = SchedulesReceived (Ok schedules) -> ( { model | schedules = schedules }, Cmd.none ) - SchedulesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none ) + SchedulesReceived (Err err) -> + ( model, handleApiError err ) ToggleScheduleSelection scheduleId dayOfWeek -> let @@ -564,14 +609,15 @@ update msg model = } , Cmd.batch [ fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - TimeEntriesSaved (Err _) -> - ( { model | error = Just "Fehler beim Speichern" }, Cmd.none ) + TimeEntriesSaved (Err err) -> + ( model, handleApiError err ) PreviousWeek -> let @@ -628,8 +674,8 @@ update msg model = WeekDatesReceived (Ok weekDates) -> ( { model | weekDates = Just weekDates }, Cmd.none ) - WeekDatesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none ) + WeekDatesReceived (Err err) -> + ( model, handleApiError err ) CheckWeekHasEntries -> case model.token of @@ -642,8 +688,8 @@ update msg model = WeekHasEntriesReceived (Ok hasEntries) -> ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) - WeekHasEntriesReceived (Err _) -> - ( model, Cmd.none ) + WeekHasEntriesReceived (Err err) -> + ( model, handleApiError err ) SetTime time -> let @@ -740,14 +786,17 @@ update msg model = , selectedEntries = [] , hasEntriesForCurrentWeek = False } - , fetchMyTimeEntries token + , Cmd.batch + [ fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - WeekEntriesDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + WeekEntriesDeleted (Err err) -> + ( model, handleApiError err ) SwitchTab tab -> let @@ -844,7 +893,7 @@ update msg model = || String.isEmpty model.newSchedule.startTime || String.isEmpty model.newSchedule.endTime then - ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) else case model.token of @@ -866,37 +915,17 @@ update msg model = , error = Nothing , isProcessing = False } - , fetchSchedules model.token + , Cmd.batch + [ fetchSchedules model.token + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) ScheduleCreated (Err err) -> - let - errorMsg = - case err of - Http.BadStatus 400 -> - "Ungültige Eingabe" - - Http.BadStatus 409 -> - "Dieser Stundenplan existiert bereits" - - Http.Timeout -> - "Anfrage abgelaufen" - - Http.NetworkError -> - "Netzwerkfehler" - - _ -> - "Fehler beim Erstellen" - in - ( { model - | error = Just errorMsg - , isProcessing = False - } - , Cmd.none - ) + ( { model | isProcessing = False }, handleApiError err ) DeleteSchedule scheduleId -> case model.token of @@ -909,13 +938,18 @@ update msg model = ScheduleDeleted (Ok _) -> case model.token of Just token -> - ( { model | error = Nothing }, fetchSchedules (Just token) ) + ( { model | error = Nothing } + , Cmd.batch + [ fetchSchedules (Just token) + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) Nothing -> ( model, Cmd.none ) - ScheduleDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + ScheduleDeleted (Err err) -> + ( model, handleApiError err ) UpdateNewUsername username -> let @@ -962,13 +996,18 @@ update msg model = in case model.token of Just token -> - ( { model | newUser = emptyUser }, fetchUsers token ) + ( { model | newUser = emptyUser } + , Cmd.batch + [ fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) Nothing -> ( model, Cmd.none ) - UserCreated (Err _) -> - ( { model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none ) + UserCreated (Err err) -> + ( model, handleApiError err ) DeleteUser userId -> case model.token of @@ -987,14 +1026,17 @@ update msg model = , editingUserId = Nothing , resetPasswordUserId = Nothing } - , fetchUsers token + , Cmd.batch + [ fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - UserDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing }, Cmd.none ) + UserDeleted (Err err) -> + ( { model | pendingDeleteId = Nothing }, handleApiError err ) FetchUsers -> case model.token of @@ -1007,8 +1049,8 @@ update msg model = UsersReceived (Ok users) -> ( { model | users = users }, Cmd.none ) - UsersReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none ) + UsersReceived (Err err) -> + ( model, handleApiError err ) FetchMyTimeEntries -> case model.token of @@ -1039,8 +1081,8 @@ update msg model = , Cmd.none ) - MyTimeEntriesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none ) + MyTimeEntriesReceived (Err err) -> + ( model, handleApiError err ) FetchAllTimeEntries -> case model.token of @@ -1053,8 +1095,8 @@ update msg model = AllTimeEntriesReceived (Ok entries) -> ( { model | timeEntries = entries }, Cmd.none ) - AllTimeEntriesReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none ) + AllTimeEntriesReceived (Err err) -> + ( model, handleApiError err ) FetchWeeklyHours -> case model.token of @@ -1067,8 +1109,8 @@ update msg model = WeeklyHoursReceived (Ok hours) -> ( { model | weeklyHours = hours }, Cmd.none ) - WeeklyHoursReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none ) + WeeklyHoursReceived (Err err) -> + ( model, handleApiError err ) FetchYearlyHoursSummary -> case model.token of @@ -1081,8 +1123,8 @@ update msg model = YearlyHoursSummaryReceived (Ok summary) -> ( { model | yearlyHoursSummary = summary }, Cmd.none ) - YearlyHoursSummaryReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Jahresübersicht" }, Cmd.none ) + YearlyHoursSummaryReceived (Err err) -> + ( model, handleApiError err ) MyWeeklySummaryReceived (Ok summary) -> ( { model | userWeeklySummary = Just summary }, Cmd.none ) @@ -1176,16 +1218,16 @@ update msg model = } , Cmd.batch [ fetchAllTimeEntries token - , fetchWeeklyHours token , fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - TimeEntryDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing }, Cmd.none ) + TimeEntryDeleted (Err err) -> + ( { model | pendingDeleteId = Nothing }, handleApiError err ) EditUserWorkHours userId -> case List.filter (\u -> u.id == userId) model.users |> List.head of @@ -1247,18 +1289,21 @@ update msg model = ( { model | resetPasswordUserId = Nothing , resetPasswordNew = "" - , error = Just "Passwort erfolgreich zurückgesetzt" + , error = Nothing } - , case model.token of - Just token -> - fetchUsers token + , Cmd.batch + [ case model.token of + Just token -> + fetchUsers token - Nothing -> - Cmd.none + Nothing -> + Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) + ] ) - ResetPasswordSaved (Err _) -> - ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + ResetPasswordSaved (Err err) -> + ( model, handleApiError err ) StartEditingTimeEntry entryId entry -> ( { model @@ -1332,14 +1377,17 @@ update msg model = , pendingDeleteId = Nothing , error = Nothing } - , fetchAllTimeEntries token + , Cmd.batch + [ fetchAllTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - TimeEntrySaved (Err _) -> - ( { model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none ) + TimeEntrySaved (Err err) -> + ( model, handleApiError err ) ConfirmDeleteTimeEntry entryId -> ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) @@ -1379,7 +1427,7 @@ update msg model = ( model, updateUserWorkHours token userId (String.fromFloat hours) ) _ -> - ( { model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) UserWorkHoursSaved (Ok _) -> case model.token of @@ -1389,14 +1437,17 @@ update msg model = , editingUserId = Nothing , error = Nothing } - , fetchUsers token + , Cmd.batch + [ fetchUsers token + , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - UserWorkHoursSaved (Err _) -> - ( { model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none ) + UserWorkHoursSaved (Err err) -> + ( model, handleApiError err ) UpdateUserPassword input -> ( { model | userPasswordInput = input }, Cmd.none ) @@ -1408,10 +1459,10 @@ update msg model = ( model, resetUserPassword token userId model.userPasswordInput ) else - ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) _ -> - ( { model | error = Just "Passwort erforderlich" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) UserPasswordSaved (Ok _) -> ( { model @@ -1419,11 +1470,11 @@ update msg model = , selectedUserId = Nothing , error = Nothing } - , Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) ) - UserPasswordSaved (Err _) -> - ( { model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none ) + UserPasswordSaved (Err err) -> + ( model, handleApiError err ) SelectUserForManualEntry userId -> let @@ -1472,15 +1523,15 @@ update msg model = , Cmd.batch [ fetchAllTimeEntries token , fetchYearlyHoursSummary token - , fetchWeeklyHours token + , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - AdminTimeEntrySaved (Err _) -> - ( { model | error = Just "Fehler beim Erstellen des Eintrags", isProcessing = False }, Cmd.none ) + AdminTimeEntrySaved (Err err) -> + ( { model | isProcessing = False }, handleApiError err ) FetchMyInfo -> case model.token of @@ -1493,8 +1544,8 @@ update msg model = MyInfoReceived (Ok user) -> ( { model | users = [ user ] }, Cmd.none ) - MyInfoReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden deiner Daten" }, Cmd.none ) + MyInfoReceived (Err err) -> + ( model, handleApiError err ) FetchSchoolYears -> case model.token of @@ -1507,8 +1558,8 @@ update msg model = SchoolYearsReceived (Ok years) -> ( { model | schoolYears = years }, Cmd.none ) - SchoolYearsReceived (Err _) -> - ( { model | error = Just "Fehler beim Laden der Schuljahre" }, Cmd.none ) + SchoolYearsReceived (Err err) -> + ( model, handleApiError err ) FetchActiveSchoolYear -> case model.token of @@ -1560,7 +1611,7 @@ update msg model = || String.isEmpty model.newSchoolYear.startDate || String.isEmpty model.newSchoolYear.endDate then - ( { model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none ) + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) else case model.token of @@ -1578,19 +1629,17 @@ update msg model = , error = Nothing , isProcessing = False } - , fetchSchoolYears token + , Cmd.batch + [ fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] ) Nothing -> ( model, Cmd.none ) - SchoolYearCreated (Err _) -> - ( { model - | error = Just "Fehler beim Erstellen des Schuljahres" - , isProcessing = False - } - , Cmd.none - ) + SchoolYearCreated (Err err) -> + ( { model | isProcessing = False }, handleApiError err ) ActivateSchoolYear id -> case model.token of @@ -1607,14 +1656,15 @@ update msg model = , Cmd.batch [ fetchSchoolYears token , fetchActiveSchoolYear token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) ] ) Nothing -> ( model, Cmd.none ) - SchoolYearActivated (Err _) -> - ( { model | error = Just "Fehler beim Aktivieren" }, Cmd.none ) + SchoolYearActivated (Err err) -> + ( model, handleApiError err ) DeleteSchoolYear id -> case model.token of @@ -1627,13 +1677,18 @@ update msg model = SchoolYearDeleted (Ok _) -> case model.token of Just token -> - ( { model | error = Nothing }, fetchSchoolYears token ) + ( { model | error = Nothing } + , Cmd.batch + [ fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) Nothing -> ( model, Cmd.none ) - SchoolYearDeleted (Err _) -> - ( { model | error = Just "Fehler beim Löschen" }, Cmd.none ) + SchoolYearDeleted (Err err) -> + ( model, handleApiError err ) DownloadYearlySummaryPDF -> case model.token of @@ -1650,11 +1705,47 @@ update msg model = in ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) - YearlySummaryPDFReceived (Err _) -> + YearlySummaryPDFReceived (Err err) -> + ( { model | isProcessing = False }, handleApiError err ) + + ShowToast message toastType -> + let + newToast = + { id = model.nextToastId + , message = message + , toastType = toastType + , dismissible = True + } + + dismissDelay = + case toastType of + ErrorToast -> + 8000 + + SuccessToast -> + 5000 + + InfoToast -> + 5000 + + WarningToast -> + 6000 + in ( { model - | error = Just "Fehler beim Herunterladen der PDF" - , isProcessing = False + | toasts = model.toasts ++ [ newToast ] + , nextToastId = model.nextToastId + 1 } + , Task.perform (\_ -> AutoDismissToast newToast.id) + (Process.sleep dismissDelay) + ) + + DismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) + + AutoDismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } , Cmd.none ) @@ -2031,18 +2122,77 @@ calculateHours startTime endTime = -- VIEW +viewToasts : List Toast -> Html Msg +viewToasts toasts = + div [ class "toast-container" ] + (List.map viewToast toasts) + + +viewToast : Toast -> Html Msg +viewToast toast = + let + toastClass = + case toast.toastType of + ErrorToast -> + "toast-error" + + SuccessToast -> + "toast-success" + + InfoToast -> + "toast-info" + + WarningToast -> + "toast-warning" + + icon = + case toast.toastType of + ErrorToast -> + "fas fa-exclamation-circle" + + SuccessToast -> + "fas fa-check-circle" + + InfoToast -> + "fas fa-info-circle" + + WarningToast -> + "fas fa-exclamation-triangle" + in + div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] + [ div [ class "toast-content" ] + [ span [ class "toast-icon" ] + [ i [ class icon ] [] ] + , span [ class "toast-message" ] [ text toast.message ] + ] + , if toast.dismissible then + button + [ class "toast-close" + , onClick (DismissToast toast.id) + , attribute "aria-label" "Schließen" + ] + [ i [ class "fas fa-times" ] [] ] + + else + text "" + ] + + view : Model -> Html Msg view model = - div [ class "container" ] - [ case model.page of - LoginPage -> - viewLogin model + div [ class "app-container" ] + [ viewToasts model.toasts + , div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model - UserDashboard -> - viewUserDashboard model + UserDashboard -> + viewUserDashboard model - AdminDashboard -> - viewAdminDashboard model + AdminDashboard -> + viewAdminDashboard model + ] ] @@ -2054,12 +2204,6 @@ viewLogin model = [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] [ div [ class "box" ] [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] - , case model.error of - Just err -> - div [ class "notification is-danger" ] [ text err ] - - Nothing -> - text "" , div [ class "field" ] [ label [ class "label" ] [ text "Benutzername" ] , div [ class "control" ] @@ -2249,12 +2393,6 @@ viewUserDashboard model = text "" , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] , viewUserYearlyTotal model - , case model.error of - Just err -> - div [ class "notification is-danger mt-4" ] [ text err ] - - Nothing -> - text "" ] ] ] @@ -4311,3 +4449,50 @@ downloadYearlySummaryPDF token = , timeout = Nothing , tracker = Nothing } + + +type alias ApiError = + { code : String + , message : String + } + + +apiErrorDecoder : Decoder ApiError +apiErrorDecoder = + Decode.map2 ApiError + (field "code" string) + (field "message" string) + + +handleApiError : Http.Error -> Cmd Msg +handleApiError error = + let + message = + case error of + Http.BadBody body -> + case Decode.decodeString apiErrorDecoder body of + Ok apiErr -> + apiErr.message + + Err _ -> + "Ein Fehler ist aufgetreten" + + Http.BadStatus 401 -> + "Keine Berechtigung - bitte erneut anmelden" + + Http.BadStatus 403 -> + "Zugriff verweigert" + + Http.BadStatus 404 -> + "Ressource nicht gefunden" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Ein unerwarteter Fehler ist aufgetreten" + in + Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) From 34834f2eaaf572802f971053a879b216482325be Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 17:35:10 +0100 Subject: [PATCH 10/13] feat: add delete schoolyear route and handler --- backend/database.go | 28 ++++++++++++++++++++++++++++ backend/handlers.go | 23 +++++++++++++++++++++++ backend/main.go | 1 + 3 files changed, 52 insertions(+) diff --git a/backend/database.go b/backend/database.go index bd15b02..7987953 100644 --- a/backend/database.go +++ b/backend/database.go @@ -580,3 +580,31 @@ func calculateHours(entry TimeEntry) float64 { return calculateHoursDiff(entry.StartTime, entry.EndTime) } } + +func DeleteSchoolYear(db *sql.DB, id int) error { + var isActive bool + err := db.QueryRow("SELECT is_active FROM school_years WHERE id = ?", id).Scan(&isActive) + if err != nil { + return err + } + + if isActive { + return fmt.Errorf("cannot delete active school year") + } + + result, err := db.Exec("DELETE FROM school_years WHERE id = ? AND is_active = 0", id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} diff --git a/backend/handlers.go b/backend/handlers.go index 067a4ea..22d4e92 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -690,3 +690,26 @@ func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error { return c.Blob(http.StatusOK, "application/pdf", pdfBytes) } + +func (app *App) DeleteSchoolYearHandler(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID")) + } + + if err := DeleteSchoolYear(app.DB, id); err != nil { + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Schuljahr")) + } + if err.Error() == "cannot delete active school year" { + return HandleError(c, &AppError{ + Code: "CANNOT_DELETE_ACTIVE_SCHOOL_YEAR", + Message: "Aktives Schuljahr kann nicht gelöscht werden", + HTTPStatus: http.StatusBadRequest, + }) + } + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/backend/main.go b/backend/main.go index 7e1903e..84cb7f1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -80,6 +80,7 @@ func main() { admin.POST("/time-entry", app.AdminCreateTimeEntryHandler) admin.GET("/school-years", app.GetSchoolYearsHandler) admin.POST("/school-years", app.CreateSchoolYearHandler) + admin.DELETE("/school-years/:id", app.DeleteSchoolYearHandler) admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler) admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler) } From 8958fd312d7281f569ac157d845c51144ae1d414 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 21:56:54 +0100 Subject: [PATCH 11/13] docs: update Readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4922c95..49e4e1f 100644 --- a/README.md +++ b/README.md @@ -770,6 +770,6 @@ Todo --- -**Version**: 1.1.0 +**Version**: 1.4.1 **Letztes Update**: November 2025 **Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter From 55b36e5e62fd82129d38d8b7353793e8d73aecd5 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 23:22:49 +0100 Subject: [PATCH 12/13] fix: fix while deleting timeentries for whole week old entries have not been deleted, before new entries have been added. This has been fixed. Also manual entries by administrators are know protected and can only be deleted by an administrator. --- backend/database.go | 16 ++++++++++++++++ backend/handlers.go | 15 ++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/backend/database.go b/backend/database.go index 7987953..66f3e54 100644 --- a/backend/database.go +++ b/backend/database.go @@ -608,3 +608,19 @@ func DeleteSchoolYear(db *sql.DB, id int) error { return nil } + +func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error { + dates := calculateWeekDates(year, week) + var dateList []string + for day := 0; day <= 4; day++ { + dateList = append(dateList, dates.Dates[fmt.Sprint(day)]) + } + + query := `DELETE FROM time_entries + WHERE user_id = ? + AND type != 'manual' + AND date IN (?, ?, ?, ?, ?)` + + _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]) + return err +} diff --git a/backend/handlers.go b/backend/handlers.go index 22d4e92..06b3f57 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -340,7 +340,7 @@ func (app *App) DeleteWeekEntries(c echo.Context) error { return HandleError(c, ErrInvalidInputMsg("Woche")) } - if err := DeleteTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { return HandleError(c, ErrDatabaseMsg(err)) } @@ -417,6 +417,19 @@ func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { return HandleError(c, ErrMissingFieldMsg("Zeiteinträge")) } + if len(req.Entries) > 0 { + firstDate := req.Entries[0].Date + t, err := time.Parse("2006-01-02", firstDate) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Datum-Format")) + } + year, week := t.ISOWeek() + + if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + } + tx, err := app.DB.Begin() if err != nil { return HandleError(c, ErrDatabaseMsg(err)) From ccae467ceb8dfd59c295e58dd3c1dd34b30d7a0b Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Sun, 9 Nov 2025 23:24:32 +0100 Subject: [PATCH 13/13] docs: update Readme --- README.md | 20 +- frontend/src/Api/Auth.elm | 21 + frontend/src/Api/Decoders.elm | 109 + frontend/src/Api/Schedule.elm | 120 + frontend/src/Api/SchoolYear.elm | 85 + frontend/src/Api/TimeEntry.elm | 201 + frontend/src/Api/User.elm | 110 + frontend/src/Main.elm | 4400 +------------------ frontend/src/Types/Api.elm | 17 + frontend/src/Types/Model.elm | 218 + frontend/src/Types/Msg.elm | 133 + frontend/src/Types/Page.elm | 17 + frontend/src/Update/AuthUpdate.elm | 115 + frontend/src/Update/ScheduleUpdate.elm | 244 + frontend/src/Update/SchoolYearUpdate.elm | 139 + frontend/src/Update/TimeEntryUpdate.elm | 189 + frontend/src/Update/Update.elm | 811 ++++ frontend/src/Update/UserUpdate.elm | 196 + frontend/src/Utils/DateUtils.elm | 338 ++ frontend/src/Utils/ErrorHandler.elm | 42 + frontend/src/Utils/Ports.elm | 20 + frontend/src/Utils/TimeUtils.elm | 34 + frontend/src/View/AdminDashboard.elm | 1165 +++++ frontend/src/View/Components/Navigation.elm | 99 + frontend/src/View/Components/Schedule.elm | 76 + frontend/src/View/Components/Toast.elm | 66 + frontend/src/View/Login.elm | 57 + frontend/src/View/UserDashboard.elm | 338 ++ frontend/src/View/View.elm | 29 + 29 files changed, 5012 insertions(+), 4397 deletions(-) create mode 100644 frontend/src/Api/Auth.elm create mode 100644 frontend/src/Api/Decoders.elm create mode 100644 frontend/src/Api/Schedule.elm create mode 100644 frontend/src/Api/SchoolYear.elm create mode 100644 frontend/src/Api/TimeEntry.elm create mode 100644 frontend/src/Api/User.elm create mode 100644 frontend/src/Types/Api.elm create mode 100644 frontend/src/Types/Model.elm create mode 100644 frontend/src/Types/Msg.elm create mode 100644 frontend/src/Types/Page.elm create mode 100644 frontend/src/Update/AuthUpdate.elm create mode 100644 frontend/src/Update/ScheduleUpdate.elm create mode 100644 frontend/src/Update/SchoolYearUpdate.elm create mode 100644 frontend/src/Update/TimeEntryUpdate.elm create mode 100644 frontend/src/Update/Update.elm create mode 100644 frontend/src/Update/UserUpdate.elm create mode 100644 frontend/src/Utils/DateUtils.elm create mode 100644 frontend/src/Utils/ErrorHandler.elm create mode 100644 frontend/src/Utils/Ports.elm create mode 100644 frontend/src/Utils/TimeUtils.elm create mode 100644 frontend/src/View/AdminDashboard.elm create mode 100644 frontend/src/View/Components/Navigation.elm create mode 100644 frontend/src/View/Components/Schedule.elm create mode 100644 frontend/src/View/Components/Toast.elm create mode 100644 frontend/src/View/Login.elm create mode 100644 frontend/src/View/UserDashboard.elm create mode 100644 frontend/src/View/View.elm diff --git a/README.md b/README.md index 4922c95..732cdbb 100644 --- a/README.md +++ b/README.md @@ -179,15 +179,15 @@ export JWT_SECRET=development-secret ### Umgebungsvariablen -| Variable | Beschreibung | Standard | Erforderlich | -| ------------------------ | ------------------------------------------------ | --------------------------------- | ------------ | -| `PORT` | HTTP-Server Port | `8080` | Nein | -| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | -| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | -| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | -| `TZ` | Zeitzone | `Europe/Berlin` | Nein | -| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | -| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | +| Variable | Beschreibung | Standard | Erforderlich | +| ------------------------ | ------------------------------------------------- | ----------------------------------------------- | ------------ | +| `PORT` | HTTP-Server Port | `8080` | Nein | +| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | +| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | +| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | +| `TZ` | Zeitzone | `Europe/Berlin` | Nein | +| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | +| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | ### Docker-Volumes @@ -770,6 +770,6 @@ Todo --- -**Version**: 1.1.0 +**Version**: 1.5.0 **Letztes Update**: November 2025 **Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm new file mode 100644 index 0000000..0de5c4e --- /dev/null +++ b/frontend/src/Api/Auth.elm @@ -0,0 +1,21 @@ +module Api.Auth exposing (loginRequest) + +import Api.Decoders exposing (loginDecoder) +import Http +import Json.Encode as Encode +import Types.Api exposing (LoginResult) +import Types.Msg exposing (Msg(..)) + + +loginRequest : String -> String -> Cmd Msg +loginRequest username password = + Http.post + { url = "/api/login" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string username ) + , ( "password", Encode.string password ) + ] + , expect = Http.expectJson LoginResponse loginDecoder + } diff --git a/frontend/src/Api/Decoders.elm b/frontend/src/Api/Decoders.elm new file mode 100644 index 0000000..cb72efa --- /dev/null +++ b/frontend/src/Api/Decoders.elm @@ -0,0 +1,109 @@ +module Api.Decoders exposing + ( apiErrorDecoder + , loginDecoder + , scheduleDecoder + , schoolYearDecoder + , timeEntryDecoder + , userDecoder + , weekDatesDecoder + , weeklyHoursDecoder + , yearlyHoursSummaryDecoder + ) + +import Dict +import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) +import Types.Api exposing (ApiError, LoginResult) +import Types.Model exposing (..) + + +loginDecoder : Decoder LoginResult +loginDecoder = + Decode.map3 LoginResult + (field "token" string) + (field "username" string) + (field "is_admin" bool) + + +scheduleDecoder : Decoder Schedule +scheduleDecoder = + Decode.map6 Schedule + (field "id" int) + (field "day_of_week" int) + (field "start_time" string) + (field "end_time" string) + (field "type" string) + (field "title" string) + + +timeEntryDecoder : Decoder TimeEntry +timeEntryDecoder = + Decode.map8 TimeEntry + (field "id" int) + (field "user_id" int) + (field "schedule_id" int) + (field "date" string) + (field "type" string) + (field "username" string) + (field "start_time" string) + (field "end_time" string) + + +userDecoder : Decoder User +userDecoder = + Decode.map4 User + (field "id" int) + (field "username" string) + (field "is_admin" bool) + (field "yearly_hours" float) + + +weekDatesDecoder : Decoder WeekDates +weekDatesDecoder = + Decode.map4 WeekDates + (field "year" int) + (field "week" int) + (field "dates" (Decode.dict string) |> Decode.map Dict.toList) + (field "range" string) + + +weeklyHoursDecoder : Decoder WeeklyHours +weeklyHoursDecoder = + Decode.map7 WeeklyHours + (field "user_id" int) + (field "username" string) + (field "year" int) + (field "week" int) + (field "total_hours" float) + (field "expected_hours" float) + (field "remaining_hours" float) + + +yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary +yearlyHoursSummaryDecoder = + Decode.succeed YearlyHoursSummary + |> Decode.andThen (\f -> Decode.map f (field "user_id" int)) + |> Decode.andThen (\f -> Decode.map f (field "username" string)) + |> Decode.andThen (\f -> Decode.map f (field "year" int)) + |> Decode.andThen (\f -> Decode.map f (field "week" int)) + |> Decode.andThen (\f -> Decode.map f (field "total_hours" float)) + |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float)) + |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float)) + |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float)) + |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float)) + + +schoolYearDecoder : Decoder SchoolYear +schoolYearDecoder = + Decode.map5 SchoolYear + (field "id" int) + (field "name" string) + (field "start_date" string) + (field "end_date" string) + (field "is_active" bool) + + +apiErrorDecoder : Decoder ApiError +apiErrorDecoder = + Decode.map2 ApiError + (field "code" string) + (field "message" string) diff --git a/frontend/src/Api/Schedule.elm b/frontend/src/Api/Schedule.elm new file mode 100644 index 0000000..f966645 --- /dev/null +++ b/frontend/src/Api/Schedule.elm @@ -0,0 +1,120 @@ +module Api.Schedule exposing + ( createSchedule + , deleteSchedule + , fetchSchedules + , saveTimeEntriesForWeek + ) + +import Api.Decoders exposing (scheduleDecoder) +import Http +import Json.Decode +import Json.Encode as Encode +import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates) +import Types.Msg exposing (Msg(..)) + + +fetchSchedules : Maybe String -> Cmd Msg +fetchSchedules maybeToken = + case maybeToken of + Just token -> + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/schedules" + , body = Http.emptyBody + , expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder) + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +createSchedule : String -> NewSchedule -> Cmd Msg +createSchedule token schedule = + case String.toInt schedule.dayOfWeek of + Just day -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules" + , body = + Http.jsonBody <| + Encode.object + [ ( "day_of_week", Encode.int day ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "title", Encode.string schedule.title ) + ] + , expect = Http.expectWhatever ScheduleCreated + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +deleteSchedule : String -> Int -> Cmd Msg +deleteSchedule token scheduleId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId + , body = Http.emptyBody + , expect = Http.expectWhatever ScheduleDeleted + , timeout = Nothing + , tracker = Nothing + } + + +saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg +saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = + case maybeWeekDates of + Nothing -> + Cmd.none + + Just weekDates -> + let + getScheduleById id = + List.filter (\s -> s.id == id) schedules |> List.head + + getDateForDay dayOfWeek = + weekDates.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + + createEntryData entry = + case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of + ( Just schedule, Just dateStr ) -> + Just <| + Encode.object + [ ( "schedule_id", Encode.int entry.scheduleId ) + , ( "date", Encode.string dateStr ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + ] + + _ -> + Nothing + + entriesData = + List.filterMap createEntryData selectedEntries + in + if List.isEmpty entriesData then + Cmd.none + + else + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/time-entries/batch" + , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] + , expect = Http.expectWhatever TimeEntriesSaved + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/SchoolYear.elm b/frontend/src/Api/SchoolYear.elm new file mode 100644 index 0000000..be1fb63 --- /dev/null +++ b/frontend/src/Api/SchoolYear.elm @@ -0,0 +1,85 @@ +module Api.SchoolYear exposing + ( activateSchoolYear + , createSchoolYear + , deleteSchoolYear + , fetchActiveSchoolYear + , fetchSchoolYears + ) + +import Api.Decoders exposing (schoolYearDecoder) +import Http +import Json.Decode as Decode +import Json.Encode as Encode +import Types.Model exposing (NewSchoolYear) +import Types.Msg exposing (Msg(..)) + + +fetchSchoolYears : String -> Cmd Msg +fetchSchoolYears token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years" + , body = Http.emptyBody + , expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchActiveSchoolYear : String -> Cmd Msg +fetchActiveSchoolYear token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/school-year/active" + , body = Http.emptyBody + , expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder + , timeout = Nothing + , tracker = Nothing + } + + +createSchoolYear : String -> NewSchoolYear -> Cmd Msg +createSchoolYear token schoolYear = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years" + , body = + Http.jsonBody <| + Encode.object + [ ( "name", Encode.string schoolYear.name ) + , ( "start_date", Encode.string schoolYear.startDate ) + , ( "end_date", Encode.string schoolYear.endDate ) + ] + , expect = Http.expectWhatever SchoolYearCreated + , timeout = Nothing + , tracker = Nothing + } + + +activateSchoolYear : String -> Int -> Cmd Msg +activateSchoolYear token id = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate" + , body = Http.emptyBody + , expect = Http.expectWhatever SchoolYearActivated + , timeout = Nothing + , tracker = Nothing + } + + +deleteSchoolYear : String -> Int -> Cmd Msg +deleteSchoolYear token id = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years/" ++ String.fromInt id + , body = Http.emptyBody + , expect = Http.expectWhatever SchoolYearDeleted + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/TimeEntry.elm b/frontend/src/Api/TimeEntry.elm new file mode 100644 index 0000000..c1ebede --- /dev/null +++ b/frontend/src/Api/TimeEntry.elm @@ -0,0 +1,201 @@ +module Api.TimeEntry exposing + ( checkWeekHasEntries + , createAdminTimeEntry + , deleteTimeEntry + , deleteWeekEntries + , downloadYearlySummaryPDF + , fetchAllTimeEntries + , fetchMyTimeEntries + , fetchWeekDates + , fetchWeeklyHours + , fetchYearlyHoursSummary + , updateTimeEntry + ) + +import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder) +import Bytes exposing (Bytes) +import Http +import Json.Decode as Decode exposing (bool, field) +import Json.Encode as Encode +import Types.Model exposing (AdminManualEntry, EditingTimeEntry) +import Types.Msg exposing (Msg(..)) + + +fetchMyTimeEntries : String -> Cmd Msg +fetchMyTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries" + , body = Http.emptyBody + , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchAllTimeEntries : String -> Cmd Msg +fetchAllTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries" + , body = Http.emptyBody + , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchWeekDates : String -> Int -> Int -> Cmd Msg +fetchWeekDates token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekDatesReceived weekDatesDecoder + , timeout = Nothing + , tracker = Nothing + } + + +checkWeekHasEntries : String -> Int -> Int -> Cmd Msg +checkWeekHasEntries token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) + , timeout = Nothing + , tracker = Nothing + } + + +deleteWeekEntries : String -> Int -> Int -> Cmd Msg +deleteWeekEntries token year week = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectWhatever WeekEntriesDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg +updateTimeEntry token entry = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId + , body = + Http.jsonBody <| + Encode.object + [ ( "date", Encode.string entry.date ) + , ( "start_time", Encode.string entry.startTime ) + , ( "end_time", Encode.string entry.endTime ) + , ( "type", Encode.string entry.entryType ) + ] + , expect = Http.expectWhatever TimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + +deleteTimeEntry : String -> Int -> Cmd Msg +deleteTimeEntry token entryId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entryId + , body = Http.emptyBody + , expect = Http.expectWhatever TimeEntryDeleted + , timeout = Nothing + , tracker = Nothing + } + + +createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg +createAdminTimeEntry token entry = + case entry.selectedUserId of + Just userId -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entry" + , body = + Http.jsonBody <| + Encode.object + [ ( "user_id", Encode.int userId ) + , ( "date", Encode.string entry.date ) + , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) + , ( "type", Encode.string "manual" ) + ] + , expect = Http.expectWhatever AdminTimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +fetchYearlyHoursSummary : String -> Cmd Msg +fetchYearlyHoursSummary token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/yearly-hours-summary" + , body = Http.emptyBody + , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +downloadYearlySummaryPDF : String -> Cmd Msg +downloadYearlySummaryPDF token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/yearly-summary/pdf" + , body = Http.emptyBody + , expect = + Http.expectBytesResponse YearlySummaryPDFReceived + (\response -> + case response of + Http.GoodStatus_ _ body -> + Ok body + + Http.BadUrl_ url -> + Err (Http.BadUrl url) + + Http.Timeout_ -> + Err Http.Timeout + + Http.NetworkError_ -> + Err Http.NetworkError + + Http.BadStatus_ metadata _ -> + Err (Http.BadStatus metadata.statusCode) + ) + , timeout = Nothing + , tracker = Nothing + } + + +fetchWeeklyHours : String -> Cmd Msg +fetchWeeklyHours token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/weekly-hours" + , body = Http.emptyBody + , expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder) + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/User.elm b/frontend/src/Api/User.elm new file mode 100644 index 0000000..17c77ac --- /dev/null +++ b/frontend/src/Api/User.elm @@ -0,0 +1,110 @@ +module Api.User exposing + ( createUser + , deleteUser + , fetchMyInfo + , fetchUsers + , resetUserPassword + , updateUserWorkHours + ) + +import Api.Decoders exposing (userDecoder) +import Http +import Json.Decode as Decode +import Json.Encode as Encode +import Types.Model exposing (NewUser) +import Types.Msg exposing (Msg(..)) + + +fetchUsers : String -> Cmd Msg +fetchUsers token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/list" + , body = Http.emptyBody + , expect = Http.expectJson UsersReceived (Decode.list userDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchMyInfo : String -> Cmd Msg +fetchMyInfo token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-info" + , body = Http.emptyBody + , expect = Http.expectJson MyInfoReceived userDecoder + , timeout = Nothing + , tracker = Nothing + } + + +createUser : String -> NewUser -> Cmd Msg +createUser token user = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string user.username ) + , ( "password", Encode.string user.password ) + , ( "is_admin", Encode.bool user.isAdmin ) + ] + , expect = Http.expectWhatever UserCreated + , timeout = Nothing + , tracker = Nothing + } + + +deleteUser : String -> Int -> Cmd Msg +deleteUser token userId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/delete?id=" ++ String.fromInt userId + , body = Http.emptyBody + , expect = Http.expectWhatever UserDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateUserWorkHours : String -> Int -> String -> Cmd Msg +updateUserWorkHours token userId hours = + case String.toFloat hours of + Just workHours -> + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId + , body = + Http.jsonBody <| + Encode.object + [ ( "yearly_hours", Encode.float workHours ) ] + , expect = Http.expectWhatever UserWorkHoursSaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +resetUserPassword : String -> Int -> String -> Cmd Msg +resetUserPassword token userId newPassword = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" + , body = + Http.jsonBody <| + Encode.object + [ ( "new_password", Encode.string newPassword ) ] + , expect = Http.expectWhatever ResetPasswordSaved + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 710b286..6f29eab 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,34 +1,20 @@ -port module Main exposing (..) +module Main exposing (..) +import Api.Auth exposing (..) +import Api.Decoders exposing (..) +import Api.Schedule exposing (..) +import Api.SchoolYear exposing (..) +import Api.TimeEntry exposing (..) +import Api.User exposing (..) import Browser -import Bytes exposing (Bytes) -import Dict exposing (Dict) -import File.Download -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Http -import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) -import Json.Encode as Encode -import Process import Task import Time - - - --- PORTS - - -port saveToken : Encode.Value -> Cmd msg - - -port removeToken : () -> Cmd msg - - -port confirmDelete : String -> Cmd msg - - -port confirmDeleteResponse : (Bool -> msg) -> Sub msg +import Types.Model exposing (..) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (..) +import Update.Update exposing (update) +import Utils.Ports exposing (..) +import View.View exposing (view) @@ -45,222 +31,6 @@ main = } - --- FLAGS - - -type alias Flags = - { token : Maybe String - , isAdmin : Bool - } - - - --- MODEL - - -type alias Model = - { page : Page - , activeTab : AdminTab - , username : String - , password : String - , token : Maybe String - , isAdmin : Bool - , schedules : List Schedule - , users : List User - , timeEntries : List TimeEntry - , weeklyHours : List WeeklyHours - , yearlyHoursSummary : List YearlyHoursSummary - , selectedEntries : List SelectedEntry - , currentWeek : Int - , currentYear : Int - , weekDates : Maybe WeekDates - , currentTime : Time.Posix - , zone : Time.Zone - , newSchedule : NewSchedule - , newUser : NewUser - , error : Maybe String - , weekEditMode : Bool - , hasEntriesForCurrentWeek : Bool - , userWeeklySummary : Maybe WeeklySummary - , editingTimeEntryId : Maybe Int - , editingTimeEntry : EditingTimeEntry - , editingUserId : Maybe Int - , editingUserWorkHours : String - , resetPasswordUserId : Maybe Int - , resetPasswordNew : String - , pendingDeleteId : Maybe Int - , selectedUserId : Maybe Int - , userWorkHoursInput : String - , userPasswordInput : String - , isProcessing : Bool - , mobileMenuOpen : Bool - , adminManualEntryForm : AdminManualEntry - , schoolYears : List SchoolYear - , newSchoolYear : NewSchoolYear - , activeSchoolYear : Maybe SchoolYear - , editingSchoolYearId : Maybe Int - , toasts : List Toast - , nextToastId : Int - } - - -type ToastType - = ErrorToast - | SuccessToast - | InfoToast - | WarningToast - - -type alias Toast = - { id : Int - , message : String - , toastType : ToastType - , dismissible : Bool - } - - -type Page - = LoginPage - | UserDashboard - | AdminDashboard - - -type AdminTab - = ScheduleTab - | UsersTab - | TimeEntriesTab - | SchoolYearsTab - - -type alias Schedule = - { id : Int - , dayOfWeek : Int - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - - -type alias User = - { id : Int - , username : String - , isAdmin : Bool - , yearlyWorkHours : Float - } - - -type alias TimeEntry = - { id : Int - , userId : Int - , scheduleId : Int - , date : String - , entryType : String - , username : String - , startTime : String - , endTime : String - } - - -type alias SelectedEntry = - { scheduleId : Int - , dayOfWeek : Int - } - - -type alias NewSchedule = - { dayOfWeek : String - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - - -type alias NewUser = - { username : String - , password : String - , isAdmin : Bool - } - - -type alias WeekDates = - { year : Int - , week : Int - , dates : List ( String, String ) - , range : String - } - - -type alias WeeklySummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - - -type alias EditingTimeEntry = - { entryId : Int - , date : String - , startTime : String - , endTime : String - , entryType : String - } - - -type alias WeeklyHours = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - - -type alias YearlyHoursSummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , yearlyTarget : Float - , yearlyActual : Float - , weeklyTarget : Float - , remainingYearly : Float - } - - -type alias AdminManualEntry = - { selectedUserId : Maybe Int - , date : String - , hours : String - , entryType : String - } - - -type alias SchoolYear = - { id : Int - , name : String - , startDate : String - , endDate : String - , isActive : Bool - } - - -type alias NewSchoolYear = - { name : String - , startDate : String - , endDate : String - } - - init : Flags -> ( Model, Cmd Msg ) init flags = let @@ -346,4153 +116,9 @@ init flags = --- UPDATE - - -type Msg - = UpdateUsername String - | UpdatePassword String - | Login - | LoginResponse (Result Http.Error LoginResult) - | Logout - | SetTime Time.Posix - | FetchSchedules - | SchedulesReceived (Result Http.Error (List Schedule)) - | ToggleScheduleSelection Int Int - | SaveTimeEntries - | TimeEntriesSaved (Result Http.Error ()) - | PreviousWeek - | NextWeek - | EnableEditMode - | DisableEditMode - | DeleteWeekEntries - | WeekEntriesDeleted (Result Http.Error ()) - | SwitchTab AdminTab - | UpdateNewScheduleDay String - | UpdateNewScheduleStart String - | UpdateNewScheduleEnd String - | UpdateNewScheduleType String - | UpdateNewScheduleTitle String - | CreateSchedule - | ScheduleCreated (Result Http.Error ()) - | DeleteSchedule Int - | ScheduleDeleted (Result Http.Error ()) - | UpdateNewUsername String - | UpdateNewPassword String - | UpdateNewUserAdmin Bool - | CreateUser - | UserCreated (Result Http.Error ()) - | DeleteUser Int - | UserDeleted (Result Http.Error ()) - | FetchUsers - | UsersReceived (Result Http.Error (List User)) - | FetchMyTimeEntries - | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchAllTimeEntries - | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchWeeklyHours - | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) - | FetchYearlyHoursSummary - | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary)) - | FetchWeekDates - | WeekDatesReceived (Result Http.Error WeekDates) - | CheckWeekHasEntries - | WeekHasEntriesReceived (Result Http.Error Bool) - | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) - | EditTimeEntry Int - | CancelEditTimeEntry - | UpdateEditTimeEntryDate String - | UpdateEditTimeEntryStartTime String - | UpdateEditTimeEntryEndTime String - | UpdateEditTimeEntryType String - | SaveEditTimeEntry - | TimeEntrySaved (Result Http.Error ()) - | TimeEntryDeleted (Result Http.Error ()) - | EditUserWorkHours Int - | CancelEditUserWorkHours - | UpdateEditUserWorkHours String - | SaveUserWorkHours - | UserWorkHoursSaved (Result Http.Error ()) - | ResetUserPassword Int - | CancelResetPassword - | UpdateResetPasswordNew String - | SaveResetPassword - | ResetPasswordSaved (Result Http.Error ()) - | ConfirmDeleteTimeEntry Int - | ConfirmDeleteUser Int - | DeleteConfirmed Bool - | StartEditingTimeEntry Int TimeEntry - | CancelEditingTimeEntry - | UpdateEditingTimeEntryDate String - | UpdateEditingTimeEntryStartTime String - | UpdateEditingTimeEntryEndTime String - | UpdateEditingTimeEntryType String - | SaveEditingTimeEntry - | SelectUserForManagement Int - | UpdateUserWorkHours String - | UpdateUserPassword String - | SaveUserPassword - | UserPasswordSaved (Result Http.Error ()) - | ToggleMobileMenu - | CloseMobileMenu - | SelectUserForManualEntry Int - | UpdateManualEntryDate String - | UpdateManualEntryHours String - | UpdateManualEntryType String - | SaveAdminTimeEntry - | AdminTimeEntrySaved (Result Http.Error ()) - | FetchMyInfo - | MyInfoReceived (Result Http.Error User) - | FetchSchoolYears - | SchoolYearsReceived (Result Http.Error (List SchoolYear)) - | FetchActiveSchoolYear - | ActiveSchoolYearReceived (Result Http.Error SchoolYear) - | UpdateNewSchoolYearName String - | UpdateNewSchoolYearStart String - | UpdateNewSchoolYearEnd String - | CreateSchoolYear - | SchoolYearCreated (Result Http.Error ()) - | ActivateSchoolYear Int - | SchoolYearActivated (Result Http.Error ()) - | DeleteSchoolYear Int - | SchoolYearDeleted (Result Http.Error ()) - | DownloadYearlySummaryPDF - | YearlySummaryPDFReceived (Result Http.Error Bytes.Bytes) - | ShowToast String ToastType - | DismissToast Int - | AutoDismissToast Int - - -update : Msg -> Model -> ( Model, Cmd Msg ) -update msg model = - case msg of - ToggleMobileMenu -> - ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) - - CloseMobileMenu -> - ( { model | mobileMenuOpen = False }, Cmd.none ) - - UpdateUsername username -> - ( { model | username = username }, Cmd.none ) - - UpdatePassword password -> - ( { model | password = password }, Cmd.none ) - - Login -> - if model.isProcessing then - ( model, Cmd.none ) - - else - ( { model | isProcessing = True }, loginRequest model.username model.password ) - - LoginResponse (Ok result) -> - let - newPage = - if result.isAdmin then - AdminDashboard - - else - UserDashboard - - ( year, week ) = - getISOWeekFromPosix model.currentTime - - tokenData = - Encode.object - [ ( "token", Encode.string result.token ) - , ( "isAdmin", Encode.bool result.isAdmin ) - ] - in - ( { model - | token = Just result.token - , username = result.username - , isAdmin = result.isAdmin - , page = newPage - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ saveToken tokenData - , fetchSchedules (Just result.token) - , Task.perform (\_ -> ShowToast ("Willkommen, " ++ result.username ++ "!") SuccessToast) (Task.succeed ()) - , if not result.isAdmin then - Cmd.batch - [ fetchMyTimeEntries result.token - , fetchWeekDates result.token year week - , checkWeekHasEntries result.token year week - , fetchYearlyHoursSummary result.token - , fetchMyInfo result.token - ] - - else - Cmd.batch - [ fetchMyTimeEntries result.token - , fetchWeekDates result.token year week - , checkWeekHasEntries result.token year week - , fetchYearlyHoursSummary result.token - ] - ] - ) - - LoginResponse (Err err) -> - let - errorMsg = - case err of - Http.BadStatus 401 -> - "Benutzername oder Passwort ungültig" - - Http.Timeout -> - "Zeitüberschreitung - bitte erneut versuchen" - - Http.NetworkError -> - "Netzwerkfehler - bitte Verbindung prüfen" - - _ -> - "Anmeldung fehlgeschlagen" - in - ( { model | isProcessing = False } - , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) - ) - - Logout -> - ( { model - | page = LoginPage - , token = Nothing - , isAdmin = False - , username = "" - , password = "" - , isProcessing = False - } - , removeToken () - ) - - FetchSchedules -> - ( model, fetchSchedules model.token ) - - SchedulesReceived (Ok schedules) -> - ( { model | schedules = schedules }, Cmd.none ) - - SchedulesReceived (Err err) -> - ( model, handleApiError err ) - - ToggleScheduleSelection scheduleId dayOfWeek -> - let - entry = - { scheduleId = scheduleId, dayOfWeek = dayOfWeek } - - newSelected = - if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then - List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries - - else - entry :: model.selectedEntries - in - ( { model | selectedEntries = newSelected }, Cmd.none ) - - SaveTimeEntries -> - case model.token of - Just token -> - ( { model | error = Nothing } - , saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntriesSaved (Ok _) -> - case model.token of - Just token -> - ( { model - | error = Nothing - , weekEditMode = False - , hasEntriesForCurrentWeek = True - } - , Cmd.batch - [ fetchMyTimeEntries token - , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntriesSaved (Err err) -> - ( model, handleApiError err ) - - PreviousWeek -> - let - ( newYear, newWeek ) = - previousWeek model.currentYear model.currentWeek - in - ( { model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - } - , case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - ] - - Nothing -> - Cmd.none - ) - - NextWeek -> - let - ( newYear, newWeek ) = - nextWeek model.currentYear model.currentWeek - in - ( { model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - } - , case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - ] - - Nothing -> - Cmd.none - ) - - FetchWeekDates -> - case model.token of - Just token -> - ( model, fetchWeekDates token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekDatesReceived (Ok weekDates) -> - ( { model | weekDates = Just weekDates }, Cmd.none ) - - WeekDatesReceived (Err err) -> - ( model, handleApiError err ) - - CheckWeekHasEntries -> - case model.token of - Just token -> - ( model, checkWeekHasEntries token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekHasEntriesReceived (Ok hasEntries) -> - ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) - - WeekHasEntriesReceived (Err err) -> - ( model, handleApiError err ) - - SetTime time -> - let - ( year, week ) = - getISOWeekFromPosix time - - cmds = - case model.token of - Just token -> - if model.page == UserDashboard || model.page == LoginPage then - Cmd.batch - [ checkWeekHasEntries token year week - , fetchWeekDates token year week - , fetchMyTimeEntries token - ] - - else - Cmd.none - - Nothing -> - Cmd.none - in - ( { model - | currentTime = time - , currentWeek = week - , currentYear = year - } - , cmds - ) - - EnableEditMode -> - let - currentWeekEntries = - List.filter - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - - preSelectedEntries = - List.map - (\entry -> - let - parts = - String.split "-" entry.date - - year = - parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - - month = - parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - day = - parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - dayOfWeek = - getDayOfWeek year month day - in - { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } - ) - currentWeekEntries - in - ( { model - | weekEditMode = True - , selectedEntries = preSelectedEntries - } - , Cmd.none - ) - - DisableEditMode -> - ( { model - | weekEditMode = False - } - , Cmd.none - ) - - DeleteWeekEntries -> - case model.token of - Just token -> - ( model, deleteWeekEntries token model.currentYear model.currentWeek ) - - Nothing -> - ( model, Cmd.none ) - - WeekEntriesDeleted (Ok _) -> - case model.token of - Just token -> - ( { model - | weekEditMode = True - , selectedEntries = [] - , hasEntriesForCurrentWeek = False - } - , Cmd.batch - [ fetchMyTimeEntries token - , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - WeekEntriesDeleted (Err err) -> - ( model, handleApiError err ) - - SwitchTab tab -> - let - cmd = - case tab of - UsersTab -> - case model.token of - Just token -> - fetchUsers token - - Nothing -> - Cmd.none - - TimeEntriesTab -> - case model.token of - Just token -> - Cmd.batch - [ fetchAllTimeEntries token - , fetchYearlyHoursSummary token - ] - - Nothing -> - Cmd.none - - SchoolYearsTab -> - case model.token of - Just token -> - Cmd.batch - [ fetchSchoolYears token - , fetchActiveSchoolYear token - ] - - Nothing -> - Cmd.none - - _ -> - Cmd.none - in - ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) - - UpdateNewScheduleDay day -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | dayOfWeek = day } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleStart time -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | startTime = time } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleEnd time -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | endTime = time } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleType scheduleType -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | scheduleType = scheduleType } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - UpdateNewScheduleTitle title -> - let - oldSchedule = - model.newSchedule - - newSchedule = - { oldSchedule | title = title } - in - ( { model | newSchedule = newSchedule }, Cmd.none ) - - CreateSchedule -> - if - String.isEmpty model.newSchedule.dayOfWeek - || String.isEmpty model.newSchedule.startTime - || String.isEmpty model.newSchedule.endTime - then - ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) - - else - case model.token of - Just token -> - ( { model | isProcessing = True }, createSchedule token model.newSchedule ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleCreated (Ok _) -> - case model.token of - Just token -> - let - emptySchedule = - NewSchedule "" "" "" "lesson" "" - in - ( { model - | newSchedule = emptySchedule - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ fetchSchedules model.token - , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleCreated (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - DeleteSchedule scheduleId -> - case model.token of - Just token -> - ( model, deleteSchedule token scheduleId ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleDeleted (Ok _) -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ fetchSchedules (Just token) - , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - ScheduleDeleted (Err err) -> - ( model, handleApiError err ) - - UpdateNewUsername username -> - let - oldUser = - model.newUser - - newUser = - { oldUser | username = username } - in - ( { model | newUser = newUser }, Cmd.none ) - - UpdateNewPassword password -> - let - oldUser = - model.newUser - - newUser = - { oldUser | password = password } - in - ( { model | newUser = newUser }, Cmd.none ) - - UpdateNewUserAdmin isAdmin -> - let - oldUser = - model.newUser - - newUser = - { oldUser | isAdmin = isAdmin } - in - ( { model | newUser = newUser }, Cmd.none ) - - CreateUser -> - case model.token of - Just token -> - ( model, createUser token model.newUser ) - - Nothing -> - ( model, Cmd.none ) - - UserCreated (Ok _) -> - let - emptyUser = - NewUser "" "" False - in - case model.token of - Just token -> - ( { model | newUser = emptyUser } - , Cmd.batch - [ fetchUsers token - , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - UserCreated (Err err) -> - ( model, handleApiError err ) - - DeleteUser userId -> - case model.token of - Just token -> - ( model, deleteUser token userId ) - - Nothing -> - ( model, Cmd.none ) - - UserDeleted (Ok _) -> - case model.token of - Just token -> - ( { model - | pendingDeleteId = Nothing - , error = Nothing - , editingUserId = Nothing - , resetPasswordUserId = Nothing - } - , Cmd.batch - [ fetchUsers token - , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - UserDeleted (Err err) -> - ( { model | pendingDeleteId = Nothing }, handleApiError err ) - - FetchUsers -> - case model.token of - Just token -> - ( model, fetchUsers token ) - - Nothing -> - ( model, Cmd.none ) - - UsersReceived (Ok users) -> - ( { model | users = users }, Cmd.none ) - - UsersReceived (Err err) -> - ( model, handleApiError err ) - - FetchMyTimeEntries -> - case model.token of - Just token -> - ( model, fetchMyTimeEntries token ) - - Nothing -> - ( model, Cmd.none ) - - MyTimeEntriesReceived (Ok entries) -> - let - hasEntries = - List.any - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - entries - in - ( { model - | timeEntries = entries - , hasEntriesForCurrentWeek = hasEntries - , weekEditMode = False - } - , Cmd.none - ) - - MyTimeEntriesReceived (Err err) -> - ( model, handleApiError err ) - - FetchAllTimeEntries -> - case model.token of - Just token -> - ( model, fetchAllTimeEntries token ) - - Nothing -> - ( model, Cmd.none ) - - AllTimeEntriesReceived (Ok entries) -> - ( { model | timeEntries = entries }, Cmd.none ) - - AllTimeEntriesReceived (Err err) -> - ( model, handleApiError err ) - - FetchWeeklyHours -> - case model.token of - Just token -> - ( model, fetchWeeklyHours token ) - - Nothing -> - ( model, Cmd.none ) - - WeeklyHoursReceived (Ok hours) -> - ( { model | weeklyHours = hours }, Cmd.none ) - - WeeklyHoursReceived (Err err) -> - ( model, handleApiError err ) - - FetchYearlyHoursSummary -> - case model.token of - Just token -> - ( model, fetchYearlyHoursSummary token ) - - Nothing -> - ( model, Cmd.none ) - - YearlyHoursSummaryReceived (Ok summary) -> - ( { model | yearlyHoursSummary = summary }, Cmd.none ) - - YearlyHoursSummaryReceived (Err err) -> - ( model, handleApiError err ) - - MyWeeklySummaryReceived (Ok summary) -> - ( { model | userWeeklySummary = Just summary }, Cmd.none ) - - MyWeeklySummaryReceived (Err _) -> - ( { model | userWeeklySummary = Nothing }, Cmd.none ) - - EditTimeEntry entryId -> - case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of - Just entry -> - ( { model - | editingTimeEntryId = Just entryId - , editingTimeEntry = - { entryId = entryId - , date = entry.date - , startTime = entry.startTime - , endTime = entry.endTime - , entryType = entry.entryType - } - } - , Cmd.none - ) - - Nothing -> - ( model, Cmd.none ) - - CancelEditTimeEntry -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" - } - , Cmd.none - ) - - UpdateEditTimeEntryDate date -> - let - old = - model.editingTimeEntry - - new = - { old | date = date } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryStartTime time -> - let - old = - model.editingTimeEntry - - new = - { old | startTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryEndTime time -> - let - old = - model.editingTimeEntry - - new = - { old | endTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditTimeEntryType entryType -> - let - old = - model.editingTimeEntry - - new = - { old | entryType = entryType } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - SaveEditTimeEntry -> - case model.token of - Just token -> - ( model, updateTimeEntry token model.editingTimeEntry ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntryDeleted (Ok _) -> - case model.token of - Just token -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - , pendingDeleteId = Nothing - , error = Nothing - } - , Cmd.batch - [ fetchAllTimeEntries token - , fetchYearlyHoursSummary token - , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntryDeleted (Err err) -> - ( { model | pendingDeleteId = Nothing }, handleApiError err ) - - EditUserWorkHours userId -> - case List.filter (\u -> u.id == userId) model.users |> List.head of - Just user -> - ( { model - | editingUserId = Just userId - , editingUserWorkHours = String.fromFloat user.yearlyWorkHours - } - , Cmd.none - ) - - Nothing -> - ( model, Cmd.none ) - - CancelEditUserWorkHours -> - ( { model - | editingUserId = Nothing - , editingUserWorkHours = "" - } - , Cmd.none - ) - - UpdateEditUserWorkHours hours -> - ( { model | editingUserWorkHours = hours }, Cmd.none ) - - ResetUserPassword userId -> - ( { model - | resetPasswordUserId = Just userId - , resetPasswordNew = "" - } - , Cmd.none - ) - - CancelResetPassword -> - ( { model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - } - , Cmd.none - ) - - UpdateResetPasswordNew password -> - ( { model | resetPasswordNew = password }, Cmd.none ) - - SaveResetPassword -> - case model.resetPasswordUserId of - Just userId -> - case model.token of - Just token -> - ( model, resetUserPassword token userId model.resetPasswordNew ) - - Nothing -> - ( model, Cmd.none ) - - Nothing -> - ( model, Cmd.none ) - - ResetPasswordSaved (Ok _) -> - ( { model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - , error = Nothing - } - , Cmd.batch - [ case model.token of - Just token -> - fetchUsers token - - Nothing -> - Cmd.none - , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) - ] - ) - - ResetPasswordSaved (Err err) -> - ( model, handleApiError err ) - - StartEditingTimeEntry entryId entry -> - ( { model - | editingTimeEntryId = Just entryId - , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType - } - , Cmd.none - ) - - CancelEditingTimeEntry -> - ( { model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - } - , Cmd.none - ) - - UpdateEditingTimeEntryDate date -> - let - old = - model.editingTimeEntry - - new = - { old | date = date } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryStartTime time -> - let - old = - model.editingTimeEntry - - new = - { old | startTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryEndTime time -> - let - old = - model.editingTimeEntry - - new = - { old | endTime = time } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - UpdateEditingTimeEntryType entryType -> - let - old = - model.editingTimeEntry - - new = - { old | entryType = entryType } - in - ( { model | editingTimeEntry = new }, Cmd.none ) - - SaveEditingTimeEntry -> - case ( model.token, model.editingTimeEntryId ) of - ( Just token, Just entryId ) -> - ( model, updateTimeEntry token model.editingTimeEntry ) - - _ -> - ( model, Cmd.none ) - - TimeEntrySaved (Ok _) -> - case model.token of - Just token -> - ( { model - | editingTimeEntryId = Nothing - , pendingDeleteId = Nothing - , error = Nothing - } - , Cmd.batch - [ fetchAllTimeEntries token - , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - TimeEntrySaved (Err err) -> - ( model, handleApiError err ) - - ConfirmDeleteTimeEntry entryId -> - ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) - - ConfirmDeleteUser userId -> - ( { model | pendingDeleteId = Just userId }, confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) - - DeleteConfirmed confirmed -> - if confirmed then - case ( model.token, model.pendingDeleteId ) of - ( Just token, Just id ) -> - let - isTimeEntry = - List.any (\e -> e.id == id) model.timeEntries - in - if isTimeEntry then - ( model, deleteTimeEntry token id ) - - else - ( model, deleteUser token id ) - - _ -> - ( model, Cmd.none ) - - else - ( { model | pendingDeleteId = Nothing }, Cmd.none ) - - SelectUserForManagement userId -> - ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) - - UpdateUserWorkHours input -> - ( { model | userWorkHoursInput = input }, Cmd.none ) - - SaveUserWorkHours -> - case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of - ( Just token, Just userId, Just hours ) -> - ( model, updateUserWorkHours token userId (String.fromFloat hours) ) - - _ -> - ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) - - UserWorkHoursSaved (Ok _) -> - case model.token of - Just token -> - ( { model - | editingUserWorkHours = "" - , editingUserId = Nothing - , error = Nothing - } - , Cmd.batch - [ fetchUsers token - , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - UserWorkHoursSaved (Err err) -> - ( model, handleApiError err ) - - UpdateUserPassword input -> - ( { model | userPasswordInput = input }, Cmd.none ) - - SaveUserPassword -> - case ( model.token, model.selectedUserId ) of - ( Just token, Just userId ) -> - if String.length model.userPasswordInput > 0 then - ( model, resetUserPassword token userId model.userPasswordInput ) - - else - ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) - - _ -> - ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) - - UserPasswordSaved (Ok _) -> - ( { model - | userPasswordInput = "" - , selectedUserId = Nothing - , error = Nothing - } - , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) - ) - - UserPasswordSaved (Err err) -> - ( model, handleApiError err ) - - SelectUserForManualEntry userId -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none ) - - UpdateManualEntryDate date -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) - - UpdateManualEntryHours hours -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) - - UpdateManualEntryType entryType -> - let - form = - model.adminManualEntryForm - in - ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none ) - - SaveAdminTimeEntry -> - case model.token of - Just token -> - ( { model | isProcessing = True }, createAdminTimeEntry token model.adminManualEntryForm ) - - Nothing -> - ( model, Cmd.none ) - - AdminTimeEntrySaved (Ok _) -> - case model.token of - Just token -> - ( { model - | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ fetchAllTimeEntries token - , fetchYearlyHoursSummary token - , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - AdminTimeEntrySaved (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - FetchMyInfo -> - case model.token of - Just token -> - ( model, fetchMyInfo token ) - - Nothing -> - ( model, Cmd.none ) - - MyInfoReceived (Ok user) -> - ( { model | users = [ user ] }, Cmd.none ) - - MyInfoReceived (Err err) -> - ( model, handleApiError err ) - - FetchSchoolYears -> - case model.token of - Just token -> - ( model, fetchSchoolYears token ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearsReceived (Ok years) -> - ( { model | schoolYears = years }, Cmd.none ) - - SchoolYearsReceived (Err err) -> - ( model, handleApiError err ) - - FetchActiveSchoolYear -> - case model.token of - Just token -> - ( model, fetchActiveSchoolYear token ) - - Nothing -> - ( model, Cmd.none ) - - ActiveSchoolYearReceived (Ok year) -> - ( { model | activeSchoolYear = Just year }, Cmd.none ) - - ActiveSchoolYearReceived (Err _) -> - ( { model | activeSchoolYear = Nothing }, Cmd.none ) - - UpdateNewSchoolYearName name -> - let - old = - model.newSchoolYear - - new = - { old | name = name } - in - ( { model | newSchoolYear = new }, Cmd.none ) - - UpdateNewSchoolYearStart date -> - let - old = - model.newSchoolYear - - new = - { old | startDate = date } - in - ( { model | newSchoolYear = new }, Cmd.none ) - - UpdateNewSchoolYearEnd date -> - let - old = - model.newSchoolYear - - new = - { old | endDate = date } - in - ( { model | newSchoolYear = new }, Cmd.none ) - - CreateSchoolYear -> - if - String.isEmpty model.newSchoolYear.name - || String.isEmpty model.newSchoolYear.startDate - || String.isEmpty model.newSchoolYear.endDate - then - ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) - - else - case model.token of - Just token -> - ( { model | isProcessing = True }, createSchoolYear token model.newSchoolYear ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearCreated (Ok _) -> - case model.token of - Just token -> - ( { model - | newSchoolYear = NewSchoolYear "" "" "" - , error = Nothing - , isProcessing = False - } - , Cmd.batch - [ fetchSchoolYears token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearCreated (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - ActivateSchoolYear id -> - case model.token of - Just token -> - ( model, activateSchoolYear token id ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearActivated (Ok _) -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ fetchSchoolYears token - , fetchActiveSchoolYear token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearActivated (Err err) -> - ( model, handleApiError err ) - - DeleteSchoolYear id -> - case model.token of - Just token -> - ( model, deleteSchoolYear token id ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearDeleted (Ok _) -> - case model.token of - Just token -> - ( { model | error = Nothing } - , Cmd.batch - [ fetchSchoolYears token - , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) - ] - ) - - Nothing -> - ( model, Cmd.none ) - - SchoolYearDeleted (Err err) -> - ( model, handleApiError err ) - - DownloadYearlySummaryPDF -> - case model.token of - Just token -> - ( { model | isProcessing = True }, downloadYearlySummaryPDF token ) - - Nothing -> - ( model, Cmd.none ) - - YearlySummaryPDFReceived (Ok pdfBytes) -> - let - filename = - "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" - in - ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) - - YearlySummaryPDFReceived (Err err) -> - ( { model | isProcessing = False }, handleApiError err ) - - ShowToast message toastType -> - let - newToast = - { id = model.nextToastId - , message = message - , toastType = toastType - , dismissible = True - } - - dismissDelay = - case toastType of - ErrorToast -> - 8000 - - SuccessToast -> - 5000 - - InfoToast -> - 5000 - - WarningToast -> - 6000 - in - ( { model - | toasts = model.toasts ++ [ newToast ] - , nextToastId = model.nextToastId + 1 - } - , Task.perform (\_ -> AutoDismissToast newToast.id) - (Process.sleep dismissDelay) - ) - - DismissToast toastId -> - ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } - , Cmd.none - ) - - AutoDismissToast toastId -> - ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } - , Cmd.none - ) - - - -- SUBSCRIPTIONS subscriptions : Model -> Sub Msg subscriptions model = confirmDeleteResponse DeleteConfirmed - - - --- HELPER FUNCTIONS - - -getISOWeekFromPosix : Time.Posix -> ( Int, Int ) -getISOWeekFromPosix time = - let - year = - Time.toYear Time.utc time - - month = - Time.toMonth Time.utc time |> monthToInt - - day = - Time.toDay Time.utc time - in - ( year, getISOWeek year month day ) - - -monthToInt : Time.Month -> Int -monthToInt month = - case month of - Time.Jan -> - 1 - - Time.Feb -> - 2 - - Time.Mar -> - 3 - - Time.Apr -> - 4 - - Time.May -> - 5 - - Time.Jun -> - 6 - - Time.Jul -> - 7 - - Time.Aug -> - 8 - - Time.Sep -> - 9 - - Time.Oct -> - 10 - - Time.Nov -> - 11 - - Time.Dec -> - 12 - - -getISOWeek : Int -> Int -> Int -> Int -getISOWeek year month day = - let - dayOfYear = - getDayOfYear year month day - - jan4DayOfWeek = - getDayOfWeek year 1 4 - - mondayOfWeek1DayOfYear = - 4 - jan4DayOfWeek - - weekNum = - ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 - in - if weekNum < 1 then - 52 - - else if weekNum > 52 then - let - dec31DayOfWeek = - getDayOfWeek year 12 31 - - jan1DayOfWeek = - getDayOfWeek year 1 1 - in - if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then - weekNum - - else - 1 - - else - weekNum - - -getDayOfYear : Int -> Int -> Int -> Int -getDayOfYear year month day = - let - daysInMonth = - [ 31 - , if isLeapYear year then - 29 - - else - 28 - , 31 - , 30 - , 31 - , 30 - , 31 - , 31 - , 30 - , 31 - , 30 - , 31 - ] - - daysBefore = - List.take (month - 1) daysInMonth |> List.sum - in - daysBefore + day - - -isLeapYear : Int -> Bool -isLeapYear year = - (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) - - -getDayOfWeek : Int -> Int -> Int -> Int -getDayOfWeek year month day = - let - adjustedMonth = - if month < 3 then - month + 12 - - else - month - - adjustedYear = - if month < 3 then - year - 1 - - else - year - - q = - day - - m = - adjustedMonth - - k = - modBy 100 adjustedYear - - j = - adjustedYear // 100 - - h = - (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 - in - (h + 5) |> modBy 7 - - -getDateForWeekDay : Int -> Int -> Int -> String -getDateForWeekDay year week dayOfWeek = - let - jan4DayOfWeek = - getDayOfWeek year 1 4 - - mondayOfWeek1Date = - 4 - jan4DayOfWeek - - targetDayOfYear = - mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek - - ( finalYear, finalMonth, finalDay ) = - if targetDayOfYear < 1 then - addDaysToDate (year - 1) 12 31 targetDayOfYear - - else - addDaysToDate year 1 targetDayOfYear 0 - in - String.fromInt finalYear - ++ "-" - ++ String.padLeft 2 '0' (String.fromInt finalMonth) - ++ "-" - ++ String.padLeft 2 '0' (String.fromInt finalDay) - - -addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) -addDaysToDate startYear startMonth startDay daysToAdd = - let - daysInMonth m y = - case m of - 1 -> - 31 - - 2 -> - if isLeapYear y then - 29 - - else - 28 - - 3 -> - 31 - - 4 -> - 30 - - 5 -> - 31 - - 6 -> - 30 - - 7 -> - 31 - - 8 -> - 31 - - 9 -> - 30 - - 10 -> - 31 - - 11 -> - 30 - - 12 -> - 31 - - _ -> - 0 - - helper y m d remaining = - if remaining == 0 then - ( y, m, d ) - - else if remaining > 0 then - let - daysInCurrentMonth = - daysInMonth m y - - daysLeftInMonth = - daysInCurrentMonth - d - in - if remaining <= daysLeftInMonth then - ( y, m, d + remaining ) - - else if m == 12 then - helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) - - else - helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) - - else if d + remaining >= 1 then - ( y, m, d + remaining ) - - else if m == 1 then - let - prevMonthDays = - daysInMonth 12 (y - 1) - in - helper (y - 1) 12 prevMonthDays (remaining + d) - - else - let - prevMonthDays = - daysInMonth (m - 1) y - in - helper y (m - 1) prevMonthDays (remaining + d) - in - helper startYear startMonth startDay daysToAdd - - -previousWeek : Int -> Int -> ( Int, Int ) -previousWeek year week = - if week == 1 then - ( year - 1, 52 ) - - else - ( year, week - 1 ) - - -nextWeek : Int -> Int -> ( Int, Int ) -nextWeek year week = - if week >= 52 then - ( year + 1, 1 ) - - else - ( year, week + 1 ) - - -getWeekDateRange : Int -> Int -> String -getWeekDateRange year week = - let - mondayDate = - getDateForWeekDay year week 0 - - fridayDate = - getDateForWeekDay year week 4 - in - mondayDate ++ " bis " ++ fridayDate - - -getYearWeekFromDate : String -> ( Int, Int ) -getYearWeekFromDate dateStr = - let - parts = - String.split "-" dateStr - - year = - parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - - month = - parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - - day = - parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - in - ( year, getISOWeek year month day ) - - -calculateHours : String -> String -> Float -calculateHours startTime endTime = - let - parseTime timeStr = - case String.split ":" timeStr of - [ h, m ] -> - (String.toFloat h |> Maybe.withDefault 0) - + ((String.toFloat m |> Maybe.withDefault 0) / 60) - - _ -> - 0 - - start = - parseTime startTime - - end = - parseTime endTime - in - if end > start then - end - start - - else if endTime == "manual" then - case String.toFloat startTime of - Just time -> - time - - Nothing -> - 0 - - else - 0 - - - --- VIEW - - -viewToasts : List Toast -> Html Msg -viewToasts toasts = - div [ class "toast-container" ] - (List.map viewToast toasts) - - -viewToast : Toast -> Html Msg -viewToast toast = - let - toastClass = - case toast.toastType of - ErrorToast -> - "toast-error" - - SuccessToast -> - "toast-success" - - InfoToast -> - "toast-info" - - WarningToast -> - "toast-warning" - - icon = - case toast.toastType of - ErrorToast -> - "fas fa-exclamation-circle" - - SuccessToast -> - "fas fa-check-circle" - - InfoToast -> - "fas fa-info-circle" - - WarningToast -> - "fas fa-exclamation-triangle" - in - div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] - [ div [ class "toast-content" ] - [ span [ class "toast-icon" ] - [ i [ class icon ] [] ] - , span [ class "toast-message" ] [ text toast.message ] - ] - , if toast.dismissible then - button - [ class "toast-close" - , onClick (DismissToast toast.id) - , attribute "aria-label" "Schließen" - ] - [ i [ class "fas fa-times" ] [] ] - - else - text "" - ] - - -view : Model -> Html Msg -view model = - div [ class "app-container" ] - [ viewToasts model.toasts - , div [ class "container" ] - [ case model.page of - LoginPage -> - viewLogin model - - UserDashboard -> - viewUserDashboard model - - AdminDashboard -> - viewAdminDashboard model - ] - ] - - -viewLogin : Model -> Html Msg -viewLogin model = - section [ class "section" ] - [ div [ class "container" ] - [ div [ class "columns is-centered" ] - [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] - [ div [ class "box" ] - [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.username - , onInput UpdateUsername - ] - [] - ] - ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.password - , onInput UpdatePassword - ] - [] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-fullwidth" - , onClick Login - ] - [ text "Anmelden" ] - ] - ] - ] - ] - ] - ] - ] - - -viewUserDashboard : Model -> Html Msg -viewUserDashboard model = - div [] - [ nav [ class "navbar is-primary" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] - ] - , a - [ class - ("navbar-burger" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - , attribute "role" "navigation" - , attribute "aria-label" "menu" - , attribute "aria-expanded" - (if model.mobileMenuOpen then - "true" - - else - "false" - ) - , onClick ToggleMobileMenu - ] - [ span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - ] - ] - , div - [ id "navbarUser" - , class - ("navbar-menu" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-2" ] [ text model.username ] - ] - , div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] - [ span [ class "icon" ] - [ i [ class "fas fa-sign-out-alt" ] [] ] - , span [] [ text "Abmelden" ] - ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ viewWeekNavigation model - , h2 [ class "title" ] [ text "Stundenplan" ] - , if model.hasEntriesForCurrentWeek && not model.weekEditMode then - div [ class "notification is-success" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-check-circle" ] [] ] - , span [] [ text "Diese Woche wurde bereits erfasst" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-warning" - , onClick EnableEditMode - , disabled model.isProcessing - ] - [ text "Bearbeiten" ] - ] - ] - ] - ] - - else if model.weekEditMode then - div [ class "notification is-warning" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-edit" ] [] ] - , span [] [ text "Bearbeitungsmodus aktiv" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-danger is-small mr-2" - , onClick DeleteWeekEntries - , disabled model.isProcessing - ] - [ text "Einträge löschen" ] - , button - [ class "button is-light is-small" - , onClick DisableEditMode - ] - [ text "Abbrechen" ] - ] - ] - ] - ] - - else - div [ class "notification is-info is-light" ] - [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] - , viewScheduleGridWithWeek model - , if not model.hasEntriesForCurrentWeek || model.weekEditMode then - div [ class "field mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-large is-fullwidth" - , onClick SaveTimeEntries - , disabled (List.isEmpty model.selectedEntries || model.isProcessing) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text - (if model.weekEditMode then - "Änderungen speichern" - - else - "Speichern" - ) - ] - ] - ] - - else - text "" - , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] - , viewUserYearlyTotal model - ] - ] - ] - - -viewAdminDashboard : Model -> Html Msg -viewAdminDashboard model = - div [] - [ nav [ class "navbar is-danger" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] - ] - , a - [ class - ("navbar-burger" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - , attribute "aria-label" "menu" - , attribute "aria-expanded" - (if model.mobileMenuOpen then - "true" - - else - "false" - ) - , onClick ToggleMobileMenu - ] - [ span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - , span [ attribute "aria-hidden" "true" ] [] - ] - ] - , div - [ id "navbarAdmin" - , class - ("navbar-menu" - ++ (if model.mobileMenuOpen then - " is-active" - - else - "" - ) - ) - ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-2" ] [ text model.username ] - ] - , div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] - [ span [ class "icon" ] - [ i [ class "fas fa-sign-out-alt" ] [] ] - , span [] [ text "Abmelden" ] - ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ div [ class "tabs is-boxed" ] - [ ul [] - [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] - [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] - , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] - [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] - , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] - [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] - , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ] - [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ] - ] - ] - , case model.activeTab of - ScheduleTab -> - viewScheduleTab model - - UsersTab -> - viewUsersTab model - - TimeEntriesTab -> - viewTimeEntriesTab model - - SchoolYearsTab -> - viewSchoolYearsTab model - ] - ] - ] - - -viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg -viewScheduleItemWithDay model dayOfWeek schedule = - let - isSelected = - List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries - - isClickable = - (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing - - boxClass = - if isSelected then - "box has-background-success-light" - - else if isClickable then - "box has-background-white" - - else - "box has-background-light" - - typeText = - if schedule.scheduleType == "break" then - " (Pause)" - - else - "" - - cursorStyle = - if isClickable then - "pointer" - - else - "not-allowed" - - opacity = - if isClickable || isSelected then - "1" - - else - "0.6" - in - div - [ class boxClass - , onClick - (if isClickable then - ToggleScheduleSelection schedule.id dayOfWeek - - else - FetchSchedules - ) - , style "cursor" cursorStyle - , style "margin-bottom" "0.5rem" - , style "padding" "0.75rem" - , style "opacity" opacity - , style "transition" "all 0.2s ease" - , style "border" - (if isClickable && not isSelected then - "2px solid transparent" - - else - "2px solid currentColor" - ) - ] - [ p [ class "has-text-weight-bold is-size-7" ] - [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [ class "is-size-7" ] - [ text (schedule.title ++ typeText) ] - ] - - -viewScheduleGridWithWeek : Model -> Html Msg -viewScheduleGridWithWeek model = - let - days = - [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ] - - groupedSchedules = - List.range 0 4 - |> List.map - (\day -> - ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) - ) - in - div [] - [ div [ class "is-hidden-mobile" ] - [ div [ class "table-container" ] - [ table [ class "table is-bordered is-fullwidth" ] - [ thead [] - [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) - ] - , tbody [] - [ tr [] - (List.map (viewDayColumnWithWeek model) groupedSchedules) - ] - ] - ] - ] - , div [ class "is-hidden-tablet" ] - (List.map2 (viewDayMobile model) days groupedSchedules) - ] - - -viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg -viewDayMobile model dayName ( dayOfWeek, schedules ) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - - Nothing -> - "Laden..." - in - div [ class "box mb-4" ] - [ p [ class "has-text-weight-bold has-text-centered mb-3" ] - [ text (dayName ++ " - " ++ dateForDay) ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] - - -viewUserWeeklySummary : Model -> Html Msg -viewUserWeeklySummary model = - case model.userWeeklySummary of - Just summary -> - let - progressPercent = - Basics.min 100 (summary.totalHours / summary.targetHours * 100) - - progressColor = - if summary.totalHours >= summary.targetHours then - "is-success" - - else if summary.totalHours >= summary.targetHours * 0.8 then - "is-info" - - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] - , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] - , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Verbleibend" ] - , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ] - [ text (String.fromFloat summary.remainingHours ++ " Std.") ] - , if summary.remainingHours < 0 then - p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] - - else - p [ class "subtitle is-6" ] [ text "" ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - Nothing -> - div [ class "box" ] - [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] - ] - - -viewUserYearlyTotal : Model -> Html Msg -viewUserYearlyTotal model = - let - yearlyTotal = - model.timeEntries - |> List.map - (\entry -> - if entry.entryType == "lesson" then - 1.0 - - else - calculateHours entry.startTime entry.endTime - ) - |> List.sum - - userTarget = - List.filter (\u -> not u.isAdmin) model.users - |> List.head - |> Maybe.map .yearlyWorkHours - |> Maybe.withDefault 60 - - remaining = - userTarget - yearlyTotal - - progressPercent = - Basics.min 100 (yearlyTotal / userTarget * 100) - - progressColor = - if remaining <= 0 then - "is-success" - - else if yearlyTotal >= userTarget * 0.8 then - "is-info" - - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Jahresenziel" ] - , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Geleistete Stunden" ] - , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Restliche Stunden" ] - , p - [ class - ("title is-4 " - ++ (if remaining <= 0 then - "has-text-success" - - else - "has-text-warning" - ) - ) - ] - [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - -viewScheduleTab : Model -> Html Msg -viewScheduleTab model = - div [] - [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] - , viewScheduleForm model - , viewScheduleList model - ] - - -viewUsersTab : Model -> Html Msg -viewUsersTab model = - div [] - [ h2 [ class "title" ] [ text "Benutzer verwalten" ] - , viewUserForm model - , viewUserList model - ] - - -viewTimeEntriesTab : Model -> Html Msg -viewTimeEntriesTab model = - div [] - [ h2 [ class "title" ] [ text "Jahresübersicht" ] - , viewYearlyHoursSummary model - , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] - , viewAdminManualEntryForm model - , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] - , case model.editingTimeEntryId of - Just _ -> - viewTimeEntriesEditForm model - - Nothing -> - viewTimeEntriesListWithEdit model - ] - - -viewYearlyHoursSummary : Model -> Html Msg -viewYearlyHoursSummary model = - div [ class "box" ] - [ div [ class "level mb-4" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ a - [ class "button is-info" - , onClick DownloadYearlySummaryPDF - , disabled model.isProcessing - ] - [ span [ class "icon" ] - [ i [ class "fas fa-file-pdf" ] [] ] - , span [] - [ text - (if model.isProcessing then - "Wird erstellt..." - - else - "PDF exportieren" - ) - ] - ] - ] - ] - ] - , if List.isEmpty model.yearlyHoursSummary then - p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] - , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] - , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] - , th [ class "has-text-centered" ] [ text "Status" ] - ] - ] - , tbody [] - (List.map viewYearlyHourRow model.yearlyHoursSummary) - ] - ] - - -viewYearlyHourRow : YearlyHoursSummary -> Html Msg -viewYearlyHourRow summary = - let - statusClass = - if summary.remainingYearly > 0 then - "has-text-danger" - - else if abs summary.remainingYearly < 0.5 then - "has-text-success" - - else - "has-text-warning" - in - tr [] - [ td [] [ text summary.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] - , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] - , td [ class ("has-text-centered " ++ statusClass) ] - [ if summary.remainingYearly > 0 then - text ("Offen: " ++ String.fromFloat summary.remainingYearly) - - else if summary.remainingYearly < -0.5 then - text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) - - else - text "✓ Erfüllt" - ] - ] - - -viewAdminManualEntryForm : Model -> Html Msg -viewAdminManualEntryForm model = - div [ class "box has-background-info-light" ] - [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] - , p [ class "help mb-3" ] - [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] - , div [ class "columns" ] - [ div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Mitarbeiter" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] - (option [ value "" ] [ text "-- Wählen --" ] - :: List.map - (\u -> - option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] - ) - model.users - ) - ] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.adminManualEntryForm.date - , onInput UpdateManualEntryDate - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "number" - , step "0.5" - , placeholder "z.B. 2.5 oder -1.0" - , value model.adminManualEntryForm.hours - , onInput UpdateManualEntryHours - ] - [] - ] - , p [ class "help" ] - [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-info" - , onClick SaveAdminTimeEntry - , disabled - (case model.adminManualEntryForm.selectedUserId of - Just _ -> - model.isProcessing || String.isEmpty model.adminManualEntryForm.hours - - Nothing -> - True - ) - ] - [ text "Eintrag erstellen" ] - ] - ] - ] - - -viewTimeEntriesEditForm : Model -> Html Msg -viewTimeEntriesEditForm model = - div [ class "box has-background-warning-light" ] - [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-success" - , onClick SaveEditTimeEntry - ] - [ text "Speichern" ] - ] - , div [ class "control" ] - [ button - [ class "button is-light" - , onClick CancelEditTimeEntry - ] - [ text "Abbrechen" ] - ] - ] - , viewTimeEntriesListWithEdit model - ] - - -viewTimeEntriesListWithEdit : Model -> Html Msg -viewTimeEntriesListWithEdit model = - div [ class "box" ] - [ if List.isEmpty model.timeEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) - ] - ] - - -viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithEdit model entry = - let - hours = - calculateHours entry.startTime entry.endTime - - isEditing = - model.editingTimeEntryId == Just entry.id - in - if isEditing then - tr [] - [ td [] [ text entry.username ] - , td [] - [ input - [ class "input is-small" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] - [] - ] - , td [] - [ div [ class "field is-grouped" ] - [ div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] - [] - ] - , div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] - [] - ] - ] - ] - , td [] - [ div [ class "select is-small" ] - [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - , td [ class "has-text-right" ] [ text "" ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] - ] - ] - - else - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditTimeEntry entry.id) - ] - [ text "Bearbeiten" ] - , button - [ class "button is-small is-danger" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] - [ text "Löschen" ] - ] - ] - - -viewWeekNavigation : Model -> Html Msg -viewWeekNavigation model = - let - dateRange = - case model.weekDates of - Just wd -> - wd.range - - Nothing -> - "Laden..." - in - div [ class "box" ] - [ nav [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick PreviousWeek - ] - [ span [ class "icon" ] - [ i [ class "fas fa-chevron-left" ] [] ] - , span [] [ text "Vorherige Woche" ] - ] - ] - ] - , div [ class "level-item" ] - [ div - [ style "display" "flex" - , style "flex-direction" "column" - , style "align-items" "center" - , style "gap" "0.5rem" - , style "min-width" "250px" - ] - [ p - [ class "heading" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text "Kalenderwoche" ] - , p - [ class "title is-3" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] - , p - [ class "subtitle is-6" - , style "margin" "0" - , style "line-height" "1.2" - ] - [ text dateRange ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick NextWeek - ] - [ span [] [ text "Nächste Woche" ] - , span [ class "icon" ] - [ i [ class "fas fa-chevron-right" ] [] ] - ] - ] - ] - ] - ] - - -viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg -viewDayColumnWithWeek model ( dayOfWeek, schedules ) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - - Nothing -> - "Laden..." - in - td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] - [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] - [ text dateForDay ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] - - -viewScheduleForm : Model -> Html Msg -viewScheduleForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Wochentag" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select - [ onInput UpdateNewScheduleDay - , disabled model.isProcessing - , value model.newSchedule.dayOfWeek - ] - [ option [ value "" ] [ text "Wochentag wählen" ] - , option [ value "0" ] [ text "Montag" ] - , option [ value "1" ] [ text "Dienstag" ] - , option [ value "2" ] [ text "Mittwoch" ] - , option [ value "3" ] [ text "Donnerstag" ] - , option [ value "4" ] [ text "Freitag" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.startTime - , onInput UpdateNewScheduleStart - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.endTime - , onInput UpdateNewScheduleEnd - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select - [ onInput UpdateNewScheduleType - , value model.newSchedule.scheduleType - , disabled model.isProcessing - ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Titel" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "z.B. Mathematik" - , value model.newSchedule.title - , onInput UpdateNewScheduleTitle - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary" - , onClick CreateSchedule - , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text " Hinzufügen" - ] - ] - ] - , if String.isEmpty model.newSchedule.dayOfWeek then - div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] - - else - text "" - ] - - -viewScheduleList : Model -> Html Msg -viewScheduleList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] - , table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Tag" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [] [ text "Titel" ] - , th [] [ text "Aktion" ] - ] - ] - , tbody [] - (List.map viewScheduleRow model.schedules) - ] - ] - - -viewScheduleRow : Schedule -> Html Msg -viewScheduleRow schedule = - let - dayName = - case schedule.dayOfWeek of - 0 -> - "Montag" - - 1 -> - "Dienstag" - - 2 -> - "Mittwoch" - - 3 -> - "Donnerstag" - - 4 -> - "Freitag" - - _ -> - "Unbekannt" - - typeName = - if schedule.scheduleType == "break" then - "Pause" - - else - "Unterricht" - in - tr [] - [ td [] [ text dayName ] - , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , td [] [ text typeName ] - , td [] [ text schedule.title ] - , td [] - [ button - [ class "button is-small is-danger" - , onClick (DeleteSchedule schedule.id) - ] - [ text "Löschen" ] - ] - ] - - -viewUserForm : Model -> Html Msg -viewUserForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.newUser.username - , onInput UpdateNewUsername - ] - [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.newUser.password - , onInput UpdateNewPassword - ] - [] - ] - ] - ] - , div [ class "column is-narrow" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Admin" ] - , div [ class "control" ] - [ label [ class "checkbox" ] - [ input - [ type_ "checkbox" - , checked model.newUser.isAdmin - , onCheck UpdateNewUserAdmin - ] - [] - , text " Admin-Rechte" - ] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] - ] - ] - ] - - -viewUserList : Model -> Html Msg -viewUserList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Benutzer" ] - , if List.isEmpty model.users then - p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "ID" ] - , th [] [ text "Benutzername" ] - , th [] [ text "Rolle" ] - , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewUserRowWithActions model) model.users) - ] - ] - - -viewUserRowWithActions : Model -> User -> Html Msg -viewUserRowWithActions model user = - if model.editingUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ input - [ class "input is-small" - , type_ "number" - , step "0.5" - , value model.editingUserWorkHours - , onInput UpdateEditUserWorkHours - ] - [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] - ] - ] - - else if model.resetPasswordUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ input - [ class "input is-small" - , type_ "password" - , placeholder "Neues Passwort" - , value model.resetPasswordNew - , onInput UpdateResetPasswordNew - ] - [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] - ] - ] - - else - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - - else - div [] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditUserWorkHours user.id) - ] - [ text "Arbeitszeit" ] - , button - [ class "button is-small is-warning mr-2" - , onClick (ResetUserPassword user.id) - ] - [ text "PW Reset" ] - , button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] - [ text "Löschen" ] - ] - ] - ] - - -viewUserRow : User -> Html Msg -viewUserRow user = - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] - [ text - (if user.isAdmin then - "Admin" - - else - "Benutzer" - ) - ] - , td [] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - - else - button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] - [ text "Löschen" ] - ] - ] - - -viewWeeklyHoursSummary : Model -> Html Msg -viewWeeklyHoursSummary model = - let - filteredHours = - List.filter - (\h -> h.week == model.currentWeek && h.year == model.currentYear) - model.weeklyHours - in - div [ class "box" ] - [ if List.isEmpty filteredHours then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Arbeitet" ] - , th [ class "has-text-right" ] [ text "Soll" ] - , th [ class "has-text-right" ] [ text "Verbleibend" ] - , th [] [ text "Fortschritt" ] - ] - ] - , tbody [] - (List.map viewWeeklyHoursRow filteredHours) - , tfoot [] - [ tr [ class "has-background-light" ] - [ th [] [ text "Gesamt" ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] - , th [] [ text "" ] - , th [] [ text "" ] - ] - ] - ] - ] - - -viewWeeklyHoursRow : WeeklyHours -> Html Msg -viewWeeklyHoursRow hours = - let - progressPercent = - Basics.min 100 (hours.totalHours / hours.targetHours * 100) - - progressColor = - if hours.totalHours >= hours.targetHours then - "is-success" - - else if hours.totalHours >= hours.targetHours * 0.8 then - "is-info" - - else - "is-warning" - in - tr [] - [ td [] [ text hours.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] - , td [] - [ progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [] - ] - ] - - -viewTimeEntriesList : Model -> Html Msg -viewTimeEntriesList model = - let - filteredEntries = - List.filter - (\e -> - let - ( entryYear, entryWeek ) = - getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - in - div [ class "box" ] - [ if List.isEmpty filteredEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithActions model) filteredEntries) - ] - ] - - -viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithActions model entry = - let - hours = - if entry.entryType == "lesson" then - 1.0 - - else - calculateHours entry.startTime entry.endTime - in - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [] - [ div [ class "buttons are-small" ] - [ button - [ class "button is-info is-small" - , onClick (StartEditingTimeEntry entry.id entry) - ] - [ text "Bearbeiten" ] - , button - [ class "button is-danger is-small" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] - [ text "Löschen" ] - ] - ] - ] - - -viewSchoolYearsTab : Model -> Html Msg -viewSchoolYearsTab model = - div [] - [ h2 [ class "title" ] [ text "Schuljahre verwalten" ] - , case model.activeSchoolYear of - Just schoolYear -> - div [ class "notification is-info is-light mb-4" ] - [ p [ class "has-text-weight-bold" ] - [ text ("Aktives Schuljahr: " ++ schoolYear.name) ] - , p [ class "is-size-7" ] - [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ] - ] - - Nothing -> - div [ class "notification is-warning is-light mb-4" ] - [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ] - , viewSchoolYearForm model - , viewSchoolYearsList model - ] - - -viewSchoolYearForm : Model -> Html Msg -viewSchoolYearForm model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ] - , div [ class "columns" ] - [ div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "2024/2025" - , value model.newSchoolYear.name - , onInput UpdateNewSchoolYearName - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startdatum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.newSchoolYear.startDate - , onInput UpdateNewSchoolYearStart - , disabled model.isProcessing - ] - [] - ] - ] - ] - , div [ class "column is-4" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Enddatum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.newSchoolYear.endDate - , onInput UpdateNewSchoolYearEnd - , disabled model.isProcessing - ] - [] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary" - , onClick CreateSchoolYear - , disabled - (String.isEmpty model.newSchoolYear.name - || String.isEmpty model.newSchoolYear.startDate - || String.isEmpty model.newSchoolYear.endDate - || model.isProcessing - ) - ] - [ if model.isProcessing then - span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] - - else - text "" - , text " Schuljahr erstellen" - ] - ] - ] - ] - - -viewSchoolYearsList : Model -> Html Msg -viewSchoolYearsList model = - div [ class "box mt-4" ] - [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ] - , if List.isEmpty model.schoolYears then - p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ] - - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Name" ] - , th [] [ text "Startdatum" ] - , th [] [ text "Enddatum" ] - , th [ class "has-text-centered" ] [ text "Status" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map viewSchoolYearRow model.schoolYears) - ] - ] - - -viewSchoolYearRow : SchoolYear -> Html Msg -viewSchoolYearRow schoolYear = - tr [] - [ td [] [ text schoolYear.name ] - , td [] [ text schoolYear.startDate ] - , td [] [ text schoolYear.endDate ] - , td [ class "has-text-centered" ] - [ if schoolYear.isActive then - span [ class "tag is-success" ] [ text "Aktiv" ] - - else - span [ class "tag is-light" ] [ text "Inaktiv" ] - ] - , td [ class "has-text-centered" ] - [ if not schoolYear.isActive then - button - [ class "button is-small is-info mr-2" - , onClick (ActivateSchoolYear schoolYear.id) - ] - [ text "Aktivieren" ] - - else - text "" - , button - [ class "button is-small is-danger" - , onClick (DeleteSchoolYear schoolYear.id) - ] - [ text "Löschen" ] - ] - ] - - - --- HTTP - - -type alias LoginResult = - { token : String - , username : String - , isAdmin : Bool - } - - -loginRequest : String -> String -> Cmd Msg -loginRequest username password = - Http.post - { url = "/api/login" - , body = - Http.jsonBody <| - Encode.object - [ ( "username", Encode.string username ) - , ( "password", Encode.string password ) - ] - , expect = Http.expectJson LoginResponse loginDecoder - } - - -loginDecoder : Decoder LoginResult -loginDecoder = - Decode.map3 LoginResult - (field "token" string) - (field "username" string) - (field "is_admin" bool) - - -fetchSchedules : Maybe String -> Cmd Msg -fetchSchedules maybeToken = - case maybeToken of - Just token -> - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/schedules" - , body = Http.emptyBody - , expect = Http.expectJson SchedulesReceived (Decode.list scheduleDecoder) - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -scheduleDecoder : Decoder Schedule -scheduleDecoder = - Decode.map6 Schedule - (field "id" int) - (field "day_of_week" int) - (field "start_time" string) - (field "end_time" string) - (field "type" string) - (field "title" string) - - -fetchMyTimeEntries : String -> Cmd Msg -fetchMyTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries" - , body = Http.emptyBody - , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg -saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = - case maybeWeekDates of - Nothing -> - Cmd.none - - Just weekDates -> - let - getScheduleById id = - List.filter (\s -> s.id == id) schedules |> List.head - - getDateForDay dayOfWeek = - weekDates.dates - |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - - createEntryData entry = - case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of - ( Just schedule, Just dateStr ) -> - Just <| - Encode.object - [ ( "schedule_id", Encode.int entry.scheduleId ) - , ( "date", Encode.string dateStr ) - , ( "type", Encode.string schedule.scheduleType ) - , ( "start_time", Encode.string schedule.startTime ) - , ( "end_time", Encode.string schedule.endTime ) - ] - - _ -> - Nothing - - entriesData = - List.filterMap createEntryData selectedEntries - in - if List.isEmpty entriesData then - Cmd.none - - else - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/time-entries/batch" - , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] - , expect = Http.expectWhatever TimeEntriesSaved - , timeout = Nothing - , tracker = Nothing - } - - -deleteWeekEntries : String -> Int -> Int -> Cmd Msg -deleteWeekEntries token year week = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectWhatever WeekEntriesDeleted - , timeout = Nothing - , tracker = Nothing - } - - -createSchedule : String -> NewSchedule -> Cmd Msg -createSchedule token schedule = - case String.toInt schedule.dayOfWeek of - Just day -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules" - , body = - Http.jsonBody <| - Encode.object - [ ( "day_of_week", Encode.int day ) - , ( "start_time", Encode.string schedule.startTime ) - , ( "end_time", Encode.string schedule.endTime ) - , ( "type", Encode.string schedule.scheduleType ) - , ( "title", Encode.string schedule.title ) - ] - , expect = Http.expectWhatever ScheduleCreated - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -deleteSchedule : String -> Int -> Cmd Msg -deleteSchedule token scheduleId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId - , body = Http.emptyBody - , expect = Http.expectWhatever ScheduleDeleted - , timeout = Nothing - , tracker = Nothing - } - - -createUser : String -> NewUser -> Cmd Msg -createUser token user = - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users" - , body = - Http.jsonBody <| - Encode.object - [ ( "username", Encode.string user.username ) - , ( "password", Encode.string user.password ) - , ( "is_admin", Encode.bool user.isAdmin ) - ] - , expect = Http.expectWhatever UserCreated - , timeout = Nothing - , tracker = Nothing - } - - -deleteUser : String -> Int -> Cmd Msg -deleteUser token userId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/delete?id=" ++ String.fromInt userId - , body = Http.emptyBody - , expect = Http.expectWhatever UserDeleted - , timeout = Nothing - , tracker = Nothing - } - - -fetchUsers : String -> Cmd Msg -fetchUsers token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/list" - , body = Http.emptyBody - , expect = Http.expectJson UsersReceived (Decode.list userDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -userDecoder : Decoder User -userDecoder = - Decode.map4 User - (field "id" int) - (field "username" string) - (field "is_admin" bool) - (field "yearly_hours" float) - - -fetchAllTimeEntries : String -> Cmd Msg -fetchAllTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries" - , body = Http.emptyBody - , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -timeEntryDecoder : Decoder TimeEntry -timeEntryDecoder = - Decode.map8 TimeEntry - (field "id" int) - (field "user_id" int) - (field "schedule_id" int) - (field "date" string) - (field "type" string) - (field "username" string) - (field "start_time" string) - (field "end_time" string) - - -fetchWeeklyHours : String -> Cmd Msg -fetchWeeklyHours token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/weekly-hours" - , body = Http.emptyBody - , expect = Http.expectJson WeeklyHoursReceived (Decode.list weeklyHoursDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -weeklyHoursDecoder : Decoder WeeklyHours -weeklyHoursDecoder = - Decode.map7 WeeklyHours - (field "user_id" int) - (field "username" string) - (field "year" int) - (field "week" int) - (field "total_hours" float) - (field "expected_hours" float) - (field "remaining_hours" float) - - -fetchYearlyHoursSummary : String -> Cmd Msg -fetchYearlyHoursSummary token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/yearly-hours-summary" - , body = Http.emptyBody - , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary -yearlyHoursSummaryDecoder = - Decode.succeed YearlyHoursSummary - |> Decode.andThen (\f -> Decode.map f (field "user_id" int)) - |> Decode.andThen (\f -> Decode.map f (field "username" string)) - |> Decode.andThen (\f -> Decode.map f (field "year" int)) - |> Decode.andThen (\f -> Decode.map f (field "week" int)) - |> Decode.andThen (\f -> Decode.map f (field "total_hours" float)) - |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float)) - |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float)) - |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float)) - |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float)) - - -createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg -createAdminTimeEntry token entry = - case entry.selectedUserId of - Just userId -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entry" - , body = - Http.jsonBody <| - Encode.object - [ ( "user_id", Encode.int userId ) - , ( "date", Encode.string entry.date ) - , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) - , ( "type", Encode.string "manual" ) - ] - , expect = Http.expectWhatever AdminTimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -fetchWeekDates : String -> Int -> Int -> Cmd Msg -fetchWeekDates token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekDatesReceived weekDatesDecoder - , timeout = Nothing - , tracker = Nothing - } - - -weekDatesDecoder : Decoder WeekDates -weekDatesDecoder = - Decode.map4 WeekDates - (field "year" int) - (field "week" int) - (field "dates" (Decode.dict string) |> Decode.map Dict.toList) - (field "range" string) - - -checkWeekHasEntries : String -> Int -> Int -> Cmd Msg -checkWeekHasEntries token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) - , timeout = Nothing - , tracker = Nothing - } - - -updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg -updateTimeEntry token entry = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId - , body = - Http.jsonBody <| - Encode.object - [ ( "date", Encode.string entry.date ) - , ( "start_time", Encode.string entry.startTime ) - , ( "end_time", Encode.string entry.endTime ) - , ( "type", Encode.string entry.entryType ) - ] - , expect = Http.expectWhatever TimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - - -deleteTimeEntry : String -> Int -> Cmd Msg -deleteTimeEntry token entryId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entryId - , body = Http.emptyBody - , expect = Http.expectWhatever TimeEntryDeleted - , timeout = Nothing - , tracker = Nothing - } - - -updateUserWorkHours : String -> Int -> String -> Cmd Msg -updateUserWorkHours token userId hours = - case String.toFloat hours of - Just workHours -> - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId - , body = - Http.jsonBody <| - Encode.object - [ ( "yearly_hours", Encode.float workHours ) ] - , expect = Http.expectWhatever UserWorkHoursSaved - , timeout = Nothing - , tracker = Nothing - } - - Nothing -> - Cmd.none - - -resetUserPassword : String -> Int -> String -> Cmd Msg -resetUserPassword token userId newPassword = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" - , body = - Http.jsonBody <| - Encode.object - [ ( "new_password", Encode.string newPassword ) ] - , expect = Http.expectWhatever ResetPasswordSaved - , timeout = Nothing - , tracker = Nothing - } - - -fetchMyInfo : String -> Cmd Msg -fetchMyInfo token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-info" - , body = Http.emptyBody - , expect = Http.expectJson MyInfoReceived userDecoder - , timeout = Nothing - , tracker = Nothing - } - - -fetchSchoolYears : String -> Cmd Msg -fetchSchoolYears token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years" - , body = Http.emptyBody - , expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder) - , timeout = Nothing - , tracker = Nothing - } - - -fetchActiveSchoolYear : String -> Cmd Msg -fetchActiveSchoolYear token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/school-year/active" - , body = Http.emptyBody - , expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder - , timeout = Nothing - , tracker = Nothing - } - - -createSchoolYear : String -> NewSchoolYear -> Cmd Msg -createSchoolYear token schoolYear = - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years" - , body = - Http.jsonBody <| - Encode.object - [ ( "name", Encode.string schoolYear.name ) - , ( "start_date", Encode.string schoolYear.startDate ) - , ( "end_date", Encode.string schoolYear.endDate ) - ] - , expect = Http.expectWhatever SchoolYearCreated - , timeout = Nothing - , tracker = Nothing - } - - -activateSchoolYear : String -> Int -> Cmd Msg -activateSchoolYear token id = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate" - , body = Http.emptyBody - , expect = Http.expectWhatever SchoolYearActivated - , timeout = Nothing - , tracker = Nothing - } - - -deleteSchoolYear : String -> Int -> Cmd Msg -deleteSchoolYear token id = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/school-years/" ++ String.fromInt id - , body = Http.emptyBody - , expect = Http.expectWhatever SchoolYearDeleted - , timeout = Nothing - , tracker = Nothing - } - - -schoolYearDecoder : Decoder SchoolYear -schoolYearDecoder = - Decode.map5 SchoolYear - (field "id" int) - (field "name" string) - (field "start_date" string) - (field "end_date" string) - (field "is_active" bool) - - -downloadYearlySummaryPDF : String -> Cmd Msg -downloadYearlySummaryPDF token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/yearly-summary/pdf" - , body = Http.emptyBody - , expect = - Http.expectBytesResponse YearlySummaryPDFReceived - (\response -> - case response of - Http.GoodStatus_ _ body -> - Ok body - - Http.BadUrl_ url -> - Err (Http.BadUrl url) - - Http.Timeout_ -> - Err Http.Timeout - - Http.NetworkError_ -> - Err Http.NetworkError - - Http.BadStatus_ metadata _ -> - Err (Http.BadStatus metadata.statusCode) - ) - , timeout = Nothing - , tracker = Nothing - } - - -type alias ApiError = - { code : String - , message : String - } - - -apiErrorDecoder : Decoder ApiError -apiErrorDecoder = - Decode.map2 ApiError - (field "code" string) - (field "message" string) - - -handleApiError : Http.Error -> Cmd Msg -handleApiError error = - let - message = - case error of - Http.BadBody body -> - case Decode.decodeString apiErrorDecoder body of - Ok apiErr -> - apiErr.message - - Err _ -> - "Ein Fehler ist aufgetreten" - - Http.BadStatus 401 -> - "Keine Berechtigung - bitte erneut anmelden" - - Http.BadStatus 403 -> - "Zugriff verweigert" - - Http.BadStatus 404 -> - "Ressource nicht gefunden" - - Http.Timeout -> - "Zeitüberschreitung - bitte erneut versuchen" - - Http.NetworkError -> - "Netzwerkfehler - bitte Verbindung prüfen" - - _ -> - "Ein unerwarteter Fehler ist aufgetreten" - in - Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm new file mode 100644 index 0000000..aae29d0 --- /dev/null +++ b/frontend/src/Types/Api.elm @@ -0,0 +1,17 @@ +module Types.Api exposing + ( ApiError + , LoginResult + ) + + +type alias LoginResult = + { token : String + , username : String + , isAdmin : Bool + } + + +type alias ApiError = + { code : String + , message : String + } diff --git a/frontend/src/Types/Model.elm b/frontend/src/Types/Model.elm new file mode 100644 index 0000000..64911d6 --- /dev/null +++ b/frontend/src/Types/Model.elm @@ -0,0 +1,218 @@ +module Types.Model exposing + ( AdminManualEntry + , EditingTimeEntry + , Flags + , Model + , NewSchedule + , NewSchoolYear + , NewUser + , Schedule + , SchoolYear + , SelectedEntry + , TimeEntry + , Toast + , ToastType(..) + , User + , WeekDates + , WeeklyHours + , WeeklySummary + , YearlyHoursSummary + ) + +import Time +import Types.Page exposing (AdminTab, Page) + + +type alias Model = + { page : Page + , activeTab : AdminTab + , username : String + , password : String + , token : Maybe String + , isAdmin : Bool + , schedules : List Schedule + , users : List User + , timeEntries : List TimeEntry + , weeklyHours : List WeeklyHours + , yearlyHoursSummary : List YearlyHoursSummary + , selectedEntries : List SelectedEntry + , currentWeek : Int + , currentYear : Int + , weekDates : Maybe WeekDates + , currentTime : Time.Posix + , zone : Time.Zone + , newSchedule : NewSchedule + , newUser : NewUser + , error : Maybe String + , weekEditMode : Bool + , hasEntriesForCurrentWeek : Bool + , userWeeklySummary : Maybe WeeklySummary + , editingTimeEntryId : Maybe Int + , editingTimeEntry : EditingTimeEntry + , editingUserId : Maybe Int + , editingUserWorkHours : String + , resetPasswordUserId : Maybe Int + , resetPasswordNew : String + , pendingDeleteId : Maybe Int + , selectedUserId : Maybe Int + , userWorkHoursInput : String + , userPasswordInput : String + , isProcessing : Bool + , mobileMenuOpen : Bool + , adminManualEntryForm : AdminManualEntry + , schoolYears : List SchoolYear + , newSchoolYear : NewSchoolYear + , activeSchoolYear : Maybe SchoolYear + , editingSchoolYearId : Maybe Int + , toasts : List Toast + , nextToastId : Int + } + + +type ToastType + = ErrorToast + | SuccessToast + | InfoToast + | WarningToast + + +type alias Toast = + { id : Int + , message : String + , toastType : ToastType + , dismissible : Bool + } + + +type alias Flags = + { token : Maybe String + , isAdmin : Bool + } + + +type alias Schedule = + { id : Int + , dayOfWeek : Int + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + + +type alias User = + { id : Int + , username : String + , isAdmin : Bool + , yearlyWorkHours : Float + } + + +type alias TimeEntry = + { id : Int + , userId : Int + , scheduleId : Int + , date : String + , entryType : String + , username : String + , startTime : String + , endTime : String + } + + +type alias SelectedEntry = + { scheduleId : Int + , dayOfWeek : Int + } + + +type alias NewSchedule = + { dayOfWeek : String + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + + +type alias NewUser = + { username : String + , password : String + , isAdmin : Bool + } + + +type alias WeekDates = + { year : Int + , week : Int + , dates : List ( String, String ) + , range : String + } + + +type alias WeeklySummary = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , targetHours : Float + , remainingHours : Float + } + + +type alias EditingTimeEntry = + { entryId : Int + , date : String + , startTime : String + , endTime : String + , entryType : String + } + + +type alias WeeklyHours = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , targetHours : Float + , remainingHours : Float + } + + +type alias YearlyHoursSummary = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , yearlyTarget : Float + , yearlyActual : Float + , weeklyTarget : Float + , remainingYearly : Float + } + + +type alias AdminManualEntry = + { selectedUserId : Maybe Int + , date : String + , hours : String + , entryType : String + } + + +type alias SchoolYear = + { id : Int + , name : String + , startDate : String + , endDate : String + , isActive : Bool + } + + +type alias NewSchoolYear = + { name : String + , startDate : String + , endDate : String + } diff --git a/frontend/src/Types/Msg.elm b/frontend/src/Types/Msg.elm new file mode 100644 index 0000000..4158571 --- /dev/null +++ b/frontend/src/Types/Msg.elm @@ -0,0 +1,133 @@ +module Types.Msg exposing (Msg(..)) + +import Bytes exposing (Bytes) +import Http +import Time +import Types.Api exposing (LoginResult) +import Types.Model + exposing + ( Schedule + , SchoolYear + , TimeEntry + , ToastType(..) + , User + , WeekDates + , WeeklyHours + , WeeklySummary + , YearlyHoursSummary + ) +import Types.Page exposing (AdminTab) + + +type Msg + = UpdateUsername String + | UpdatePassword String + | Login + | LoginResponse (Result Http.Error LoginResult) + | Logout + | SetTime Time.Posix + | FetchSchedules + | SchedulesReceived (Result Http.Error (List Schedule)) + | ToggleScheduleSelection Int Int + | SaveTimeEntries + | TimeEntriesSaved (Result Http.Error ()) + | PreviousWeek + | NextWeek + | EnableEditMode + | DisableEditMode + | DeleteWeekEntries + | WeekEntriesDeleted (Result Http.Error ()) + | SwitchTab AdminTab + | UpdateNewScheduleDay String + | UpdateNewScheduleStart String + | UpdateNewScheduleEnd String + | UpdateNewScheduleType String + | UpdateNewScheduleTitle String + | CreateSchedule + | ScheduleCreated (Result Http.Error ()) + | DeleteSchedule Int + | ScheduleDeleted (Result Http.Error ()) + | UpdateNewUsername String + | UpdateNewPassword String + | UpdateNewUserAdmin Bool + | CreateUser + | UserCreated (Result Http.Error ()) + | DeleteUser Int + | UserDeleted (Result Http.Error ()) + | FetchUsers + | UsersReceived (Result Http.Error (List User)) + | FetchMyTimeEntries + | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchAllTimeEntries + | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchWeeklyHours + | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) + | FetchYearlyHoursSummary + | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary)) + | FetchWeekDates + | WeekDatesReceived (Result Http.Error WeekDates) + | CheckWeekHasEntries + | WeekHasEntriesReceived (Result Http.Error Bool) + | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) + | EditTimeEntry Int + | CancelEditTimeEntry + | UpdateEditTimeEntryDate String + | UpdateEditTimeEntryStartTime String + | UpdateEditTimeEntryEndTime String + | UpdateEditTimeEntryType String + | SaveEditTimeEntry + | TimeEntrySaved (Result Http.Error ()) + | TimeEntryDeleted (Result Http.Error ()) + | EditUserWorkHours Int + | CancelEditUserWorkHours + | UpdateEditUserWorkHours String + | SaveUserWorkHours + | UserWorkHoursSaved (Result Http.Error ()) + | ResetUserPassword Int + | CancelResetPassword + | UpdateResetPasswordNew String + | SaveResetPassword + | ResetPasswordSaved (Result Http.Error ()) + | ConfirmDeleteTimeEntry Int + | ConfirmDeleteUser Int + | DeleteConfirmed Bool + | StartEditingTimeEntry Int TimeEntry + | CancelEditingTimeEntry + | UpdateEditingTimeEntryDate String + | UpdateEditingTimeEntryStartTime String + | UpdateEditingTimeEntryEndTime String + | UpdateEditingTimeEntryType String + | SaveEditingTimeEntry + | SelectUserForManagement Int + | UpdateUserWorkHours String + | UpdateUserPassword String + | SaveUserPassword + | UserPasswordSaved (Result Http.Error ()) + | ToggleMobileMenu + | CloseMobileMenu + | SelectUserForManualEntry Int + | UpdateManualEntryDate String + | UpdateManualEntryHours String + | UpdateManualEntryType String + | SaveAdminTimeEntry + | AdminTimeEntrySaved (Result Http.Error ()) + | FetchMyInfo + | MyInfoReceived (Result Http.Error User) + | FetchSchoolYears + | SchoolYearsReceived (Result Http.Error (List SchoolYear)) + | FetchActiveSchoolYear + | ActiveSchoolYearReceived (Result Http.Error SchoolYear) + | UpdateNewSchoolYearName String + | UpdateNewSchoolYearStart String + | UpdateNewSchoolYearEnd String + | CreateSchoolYear + | SchoolYearCreated (Result Http.Error ()) + | ActivateSchoolYear Int + | SchoolYearActivated (Result Http.Error ()) + | DeleteSchoolYear Int + | SchoolYearDeleted (Result Http.Error ()) + | DownloadYearlySummaryPDF + | YearlySummaryPDFReceived (Result Http.Error Bytes) + | ShowToast String ToastType + | DismissToast Int + | AutoDismissToast Int diff --git a/frontend/src/Types/Page.elm b/frontend/src/Types/Page.elm new file mode 100644 index 0000000..5b41054 --- /dev/null +++ b/frontend/src/Types/Page.elm @@ -0,0 +1,17 @@ +module Types.Page exposing + ( AdminTab(..) + , Page(..) + ) + + +type Page + = LoginPage + | UserDashboard + | AdminDashboard + + +type AdminTab + = ScheduleTab + | UsersTab + | TimeEntriesTab + | SchoolYearsTab diff --git a/frontend/src/Update/AuthUpdate.elm b/frontend/src/Update/AuthUpdate.elm new file mode 100644 index 0000000..20a1fbc --- /dev/null +++ b/frontend/src/Update/AuthUpdate.elm @@ -0,0 +1,115 @@ +module Update.AuthUpdate exposing + ( handleLogin + , handleLoginResponse + , handleLogout + ) + +import Api.Auth +import Api.Schedule +import Api.SchoolYear +import Api.TimeEntry +import Api.User +import Http +import Json.Encode as Encode +import Task +import Types.Model exposing (Model, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (Page(..)) +import Utils.DateUtils exposing (getISOWeekFromPosix) +import Utils.Ports exposing (removeToken, saveToken) + + +handleLogin : Model -> ( Model, Cmd Msg ) +handleLogin model = + if model.isProcessing then + ( model, Cmd.none ) + + else + ( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password ) + + +handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg ) +handleLoginResponse result model = + case result of + Ok loginResult -> + let + newPage = + if loginResult.isAdmin then + AdminDashboard + + else + UserDashboard + + ( year, week ) = + getISOWeekFromPosix model.currentTime + + tokenData = + Encode.object + [ ( "token", Encode.string loginResult.token ) + , ( "isAdmin", Encode.bool loginResult.isAdmin ) + ] + in + ( { model + | token = Just loginResult.token + , username = loginResult.username + , isAdmin = loginResult.isAdmin + , page = newPage + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ saveToken tokenData + , Api.Schedule.fetchSchedules (Just loginResult.token) + , Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ()) + , if not loginResult.isAdmin then + Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries loginResult.token + , Api.TimeEntry.fetchWeekDates loginResult.token year week + , Api.TimeEntry.checkWeekHasEntries loginResult.token year week + , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token + , Api.User.fetchMyInfo loginResult.token + ] + + else + Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries loginResult.token + , Api.TimeEntry.fetchWeekDates loginResult.token year week + , Api.TimeEntry.checkWeekHasEntries loginResult.token year week + , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token + ] + ] + ) + + Err err -> + let + errorMsg = + case err of + Http.BadStatus 401 -> + "Benutzername oder Passwort ungültig" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Anmeldung fehlgeschlagen" + in + ( { model | isProcessing = False } + , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) + ) + + +handleLogout : Model -> ( Model, Cmd Msg ) +handleLogout model = + ( { model + | page = LoginPage + , token = Nothing + , isAdmin = False + , username = "" + , password = "" + , isProcessing = False + } + , removeToken () + ) diff --git a/frontend/src/Update/ScheduleUpdate.elm b/frontend/src/Update/ScheduleUpdate.elm new file mode 100644 index 0000000..2312e13 --- /dev/null +++ b/frontend/src/Update/ScheduleUpdate.elm @@ -0,0 +1,244 @@ +module Update.ScheduleUpdate exposing + ( handleCreateSchedule + , handleDeleteSchedule + , handleDeleteWeekEntries + , handleDisableEditMode + , handleEnableEditMode + , handleSaveTimeEntries + , handleScheduleCreated + , handleScheduleDeleted + , handleSchedulesReceived + , handleTimeEntriesSaved + , handleToggleScheduleSelection + , handleWeekEntriesDeleted + ) + +import Api.Schedule +import Api.TimeEntry +import Http +import Task +import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate) + + +handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg ) +handleToggleScheduleSelection scheduleId dayOfWeek model = + let + entry = + { scheduleId = scheduleId, dayOfWeek = dayOfWeek } + + newSelected = + if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then + List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries + + else + entry :: model.selectedEntries + in + ( { model | selectedEntries = newSelected }, Cmd.none ) + + +handleSaveTimeEntries : Model -> ( Model, Cmd Msg ) +handleSaveTimeEntries model = + case model.token of + Just token -> + ( { model | error = Nothing } + , Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates + ) + + Nothing -> + ( model, Cmd.none ) + + +handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntriesSaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | error = Nothing + , weekEditMode = False + , hasEntriesForCurrentWeek = True + } + , Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEnableEditMode : Model -> ( Model, Cmd Msg ) +handleEnableEditMode model = + let + currentWeekEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + + preSelectedEntries = + List.map + (\entry -> + let + parts = + String.split "-" entry.date + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + dayOfWeek = + getDayOfWeek year month day + in + { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } + ) + currentWeekEntries + in + ( { model + | weekEditMode = True + , selectedEntries = preSelectedEntries + } + , Cmd.none + ) + + +handleDisableEditMode : Model -> ( Model, Cmd Msg ) +handleDisableEditMode model = + ( { model | weekEditMode = False }, Cmd.none ) + + +handleDeleteWeekEntries : Model -> ( Model, Cmd Msg ) +handleDeleteWeekEntries model = + case model.token of + Just token -> + ( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + +handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleWeekEntriesDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | weekEditMode = True + , selectedEntries = [] + , hasEntriesForCurrentWeek = False + } + , Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleCreateSchedule : Model -> ( Model, Cmd Msg ) +handleCreateSchedule model = + if + String.isEmpty model.newSchedule.dayOfWeek + || String.isEmpty model.newSchedule.startTime + || String.isEmpty model.newSchedule.endTime + then + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule ) + + Nothing -> + ( model, Cmd.none ) + + +handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleScheduleCreated result model = + case result of + Ok _ -> + case model.token of + Just token -> + let + emptySchedule = + NewSchedule "" "" "" "lesson" "" + in + ( { model + | newSchedule = emptySchedule + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.Schedule.fetchSchedules model.token + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteSchedule scheduleId model = + case model.token of + Just token -> + ( model, Api.Schedule.deleteSchedule token scheduleId ) + + Nothing -> + ( model, Cmd.none ) + + +handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleScheduleDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.Schedule.fetchSchedules (Just token) + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg ) +handleSchedulesReceived result model = + case result of + Ok schedules -> + ( { model | schedules = schedules }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Update/SchoolYearUpdate.elm b/frontend/src/Update/SchoolYearUpdate.elm new file mode 100644 index 0000000..0de741d --- /dev/null +++ b/frontend/src/Update/SchoolYearUpdate.elm @@ -0,0 +1,139 @@ +module Update.SchoolYearUpdate exposing + ( handleActivateSchoolYear + , handleActiveSchoolYearReceived + , handleCreateSchoolYear + , handleDeleteSchoolYear + , handleSchoolYearActivated + , handleSchoolYearCreated + , handleSchoolYearDeleted + , handleSchoolYearsReceived + ) + +import Api.SchoolYear +import Http +import Task +import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..)) +import Types.Msg exposing (Msg(..)) + + +handleCreateSchoolYear : Model -> ( Model, Cmd Msg ) +handleCreateSchoolYear model = + if + String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + then + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearCreated result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | newSchoolYear = NewSchoolYear "" "" "" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg ) +handleActivateSchoolYear id model = + case model.token of + Just token -> + ( model, Api.SchoolYear.activateSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearActivated result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Api.SchoolYear.fetchActiveSchoolYear token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteSchoolYear id model = + case model.token of + Just token -> + ( model, Api.SchoolYear.deleteSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg ) +handleSchoolYearsReceived result model = + case result of + Ok years -> + ( { model | schoolYears = years }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg ) +handleActiveSchoolYearReceived result model = + case result of + Ok year -> + ( { model | activeSchoolYear = Just year }, Cmd.none ) + + Err _ -> + ( { model | activeSchoolYear = Nothing }, Cmd.none ) diff --git a/frontend/src/Update/TimeEntryUpdate.elm b/frontend/src/Update/TimeEntryUpdate.elm new file mode 100644 index 0000000..a794944 --- /dev/null +++ b/frontend/src/Update/TimeEntryUpdate.elm @@ -0,0 +1,189 @@ +module Update.TimeEntryUpdate exposing + ( handleAdminTimeEntrySaved + , handleAllTimeEntriesReceived + , handleConfirmDeleteTimeEntry + , handleEditTimeEntry + , handleMyTimeEntriesReceived + , handleSaveAdminTimeEntry + , handleSaveEditTimeEntry + , handleTimeEntryDeleted + , handleTimeEntrySaved + , handleYearlyHoursSummaryReceived + ) + +import Api.TimeEntry +import Http +import Task +import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary) +import Types.Msg exposing (Msg(..)) +import Utils.DateUtils exposing (getYearWeekFromDate) +import Utils.Ports exposing (confirmDelete) + + +handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) +handleMyTimeEntriesReceived result model = + case result of + Ok entries -> + let + hasEntries = + List.any + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + entries + in + ( { model + | timeEntries = entries + , hasEntriesForCurrentWeek = hasEntries + , weekEditMode = False + } + , Cmd.none + ) + + Err err -> + ( model, Cmd.none ) + + +handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) +handleAllTimeEntriesReceived result model = + case result of + Ok entries -> + ( { model | timeEntries = entries }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg ) +handleEditTimeEntry entryId model = + case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of + Just entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = + { entryId = entryId + , date = entry.date + , startTime = entry.startTime + , endTime = entry.endTime + , entryType = entry.entryType + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + +handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg ) +handleSaveEditTimeEntry model = + case model.token of + Just token -> + ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) + + Nothing -> + ( model, Cmd.none ) + + +handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntrySaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntryDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + +handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg ) +handleConfirmDeleteTimeEntry entryId model = + ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) + + +handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg ) +handleSaveAdminTimeEntry model = + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm ) + + Nothing -> + ( model, Cmd.none ) + + +handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleAdminTimeEntrySaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg ) +handleYearlyHoursSummaryReceived result model = + case result of + Ok summary -> + ( { model | yearlyHoursSummary = summary }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Update/Update.elm b/frontend/src/Update/Update.elm new file mode 100644 index 0000000..f384b8c --- /dev/null +++ b/frontend/src/Update/Update.elm @@ -0,0 +1,811 @@ +module Update.Update exposing (update) + +import Api.Schedule +import Api.SchoolYear +import Api.TimeEntry +import Api.User +import File.Download +import Process +import Task +import Time +import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (AdminTab(..), Page(..)) +import Update.AuthUpdate as Auth +import Update.ScheduleUpdate as Schedule +import Update.SchoolYearUpdate as SchoolYear +import Update.TimeEntryUpdate as TimeEntry +import Update.UserUpdate as User +import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek) +import Utils.Ports + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + -- Mobile Menu + ToggleMobileMenu -> + ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) + + CloseMobileMenu -> + ( { model | mobileMenuOpen = False }, Cmd.none ) + + -- Auth + UpdateUsername username -> + ( { model | username = username }, Cmd.none ) + + UpdatePassword password -> + ( { model | password = password }, Cmd.none ) + + Login -> + Auth.handleLogin model + + LoginResponse result -> + Auth.handleLoginResponse result model + + Logout -> + Auth.handleLogout model + + -- Time + SetTime time -> + let + ( year, week ) = + getISOWeekFromPosix time + + cmds = + case model.token of + Just token -> + if model.page == UserDashboard || model.page == LoginPage then + Cmd.batch + [ Api.TimeEntry.checkWeekHasEntries token year week + , Api.TimeEntry.fetchWeekDates token year week + , Api.TimeEntry.fetchMyTimeEntries token + ] + + else + Cmd.none + + Nothing -> + Cmd.none + in + ( { model + | currentTime = time + , currentWeek = week + , currentYear = year + } + , cmds + ) + + -- Schedules + FetchSchedules -> + ( model, Api.Schedule.fetchSchedules model.token ) + + SchedulesReceived result -> + Schedule.handleSchedulesReceived result model + + ToggleScheduleSelection scheduleId dayOfWeek -> + Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model + + SaveTimeEntries -> + Schedule.handleSaveTimeEntries model + + TimeEntriesSaved result -> + Schedule.handleTimeEntriesSaved result model + + EnableEditMode -> + Schedule.handleEnableEditMode model + + DisableEditMode -> + Schedule.handleDisableEditMode model + + DeleteWeekEntries -> + Schedule.handleDeleteWeekEntries model + + WeekEntriesDeleted result -> + Schedule.handleWeekEntriesDeleted result model + + CreateSchedule -> + Schedule.handleCreateSchedule model + + ScheduleCreated result -> + Schedule.handleScheduleCreated result model + + DeleteSchedule scheduleId -> + Schedule.handleDeleteSchedule scheduleId model + + ScheduleDeleted result -> + Schedule.handleScheduleDeleted result model + + -- Week Navigation + PreviousWeek -> + let + ( newYear, newWeek ) = + previousWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchWeekDates token newYear newWeek + , Api.TimeEntry.checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + NextWeek -> + let + ( newYear, newWeek ) = + nextWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchWeekDates token newYear newWeek + , Api.TimeEntry.checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + FetchWeekDates -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekDatesReceived result -> + case result of + Ok weekDates -> + ( { model | weekDates = Just weekDates }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + CheckWeekHasEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekHasEntriesReceived result -> + case result of + Ok hasEntries -> + ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + -- Admin Tabs + SwitchTab tab -> + let + cmd = + case tab of + UsersTab -> + case model.token of + Just token -> + Api.User.fetchUsers token + + Nothing -> + Cmd.none + + TimeEntriesTab -> + case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + ] + + Nothing -> + Cmd.none + + SchoolYearsTab -> + case model.token of + Just token -> + Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Api.SchoolYear.fetchActiveSchoolYear token + ] + + Nothing -> + Cmd.none + + _ -> + Cmd.none + in + ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) + + -- Schedule Form + UpdateNewScheduleDay day -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | dayOfWeek = day } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleStart time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | startTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleEnd time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | endTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleType scheduleType -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | scheduleType = scheduleType } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleTitle title -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | title = title } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + -- Users + UpdateNewUsername username -> + let + oldUser = + model.newUser + + newUser = + { oldUser | username = username } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewPassword password -> + let + oldUser = + model.newUser + + newUser = + { oldUser | password = password } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewUserAdmin isAdmin -> + let + oldUser = + model.newUser + + newUser = + { oldUser | isAdmin = isAdmin } + in + ( { model | newUser = newUser }, Cmd.none ) + + CreateUser -> + User.handleCreateUser model + + UserCreated result -> + User.handleUserCreated result model + + DeleteUser userId -> + User.handleDeleteUser userId model + + UserDeleted result -> + User.handleUserDeleted result model + + FetchUsers -> + case model.token of + Just token -> + ( model, Api.User.fetchUsers token ) + + Nothing -> + ( model, Cmd.none ) + + UsersReceived result -> + User.handleUsersReceived result model + + EditUserWorkHours userId -> + User.handleEditUserWorkHours userId model + + CancelEditUserWorkHours -> + ( { model + | editingUserId = Nothing + , editingUserWorkHours = "" + } + , Cmd.none + ) + + UpdateEditUserWorkHours hours -> + ( { model | editingUserWorkHours = hours }, Cmd.none ) + + SaveUserWorkHours -> + User.handleSaveUserWorkHours model + + UserWorkHoursSaved result -> + User.handleUserWorkHoursSaved result model + + ResetUserPassword userId -> + User.handleResetUserPassword userId model + + CancelResetPassword -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + } + , Cmd.none + ) + + UpdateResetPasswordNew password -> + ( { model | resetPasswordNew = password }, Cmd.none ) + + SaveResetPassword -> + User.handleSaveResetPassword model + + ResetPasswordSaved result -> + User.handleResetPasswordSaved result model + + UpdateUserWorkHours input -> + ( { model | userWorkHoursInput = input }, Cmd.none ) + + UpdateUserPassword input -> + ( { model | userPasswordInput = input }, Cmd.none ) + + SaveUserPassword -> + case ( model.token, model.selectedUserId ) of + ( Just token, Just userId ) -> + if String.length model.userPasswordInput > 0 then + ( model, Api.User.resetUserPassword token userId model.userPasswordInput ) + + else + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) + + _ -> + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) + + UserPasswordSaved result -> + case result of + Ok _ -> + ( { model + | userPasswordInput = "" + , selectedUserId = Nothing + , error = Nothing + } + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) + ) + + Err err -> + ( model, Cmd.none ) + + SelectUserForManagement userId -> + ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) + + -- Time Entries + FetchMyTimeEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchMyTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + MyTimeEntriesReceived result -> + TimeEntry.handleMyTimeEntriesReceived result model + + FetchAllTimeEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchAllTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + AllTimeEntriesReceived result -> + TimeEntry.handleAllTimeEntriesReceived result model + + EditTimeEntry entryId -> + TimeEntry.handleEditTimeEntry entryId model + + CancelEditTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + } + , Cmd.none + ) + + UpdateEditTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditTimeEntry -> + TimeEntry.handleSaveEditTimeEntry model + + TimeEntrySaved result -> + TimeEntry.handleTimeEntrySaved result model + + TimeEntryDeleted result -> + TimeEntry.handleTimeEntryDeleted result model + + ConfirmDeleteTimeEntry entryId -> + TimeEntry.handleConfirmDeleteTimeEntry entryId model + + StartEditingTimeEntry entryId entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType + } + , Cmd.none + ) + + CancelEditingTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + } + , Cmd.none + ) + + UpdateEditingTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditingTimeEntry -> + case ( model.token, model.editingTimeEntryId ) of + ( Just token, Just entryId ) -> + ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) + + _ -> + ( model, Cmd.none ) + + -- Weekly Hours + FetchWeeklyHours -> + case model.token of + Just token -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + WeeklyHoursReceived result -> + case result of + Ok hours -> + ( { model | weeklyHours = hours }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + MyWeeklySummaryReceived result -> + case result of + Ok summary -> + ( { model | userWeeklySummary = Just summary }, Cmd.none ) + + Err _ -> + ( { model | userWeeklySummary = Nothing }, Cmd.none ) + + -- Yearly Hours + FetchYearlyHoursSummary -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchYearlyHoursSummary token ) + + Nothing -> + ( model, Cmd.none ) + + YearlyHoursSummaryReceived result -> + TimeEntry.handleYearlyHoursSummaryReceived result model + + -- Admin Manual Entry + SelectUserForManualEntry userId -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none ) + + UpdateManualEntryDate date -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) + + UpdateManualEntryHours hours -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) + + UpdateManualEntryType entryType -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none ) + + SaveAdminTimeEntry -> + TimeEntry.handleSaveAdminTimeEntry model + + AdminTimeEntrySaved result -> + TimeEntry.handleAdminTimeEntrySaved result model + + -- My Info + FetchMyInfo -> + case model.token of + Just token -> + ( model, Api.User.fetchMyInfo token ) + + Nothing -> + ( model, Cmd.none ) + + MyInfoReceived result -> + case result of + Ok user -> + ( { model | users = [ user ] }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + -- School Years + FetchSchoolYears -> + case model.token of + Just token -> + ( model, Api.SchoolYear.fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearsReceived result -> + SchoolYear.handleSchoolYearsReceived result model + + FetchActiveSchoolYear -> + case model.token of + Just token -> + ( model, Api.SchoolYear.fetchActiveSchoolYear token ) + + Nothing -> + ( model, Cmd.none ) + + ActiveSchoolYearReceived result -> + SchoolYear.handleActiveSchoolYearReceived result model + + UpdateNewSchoolYearName name -> + let + old = + model.newSchoolYear + + new = + { old | name = name } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + UpdateNewSchoolYearStart date -> + let + old = + model.newSchoolYear + + new = + { old | startDate = date } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + UpdateNewSchoolYearEnd date -> + let + old = + model.newSchoolYear + + new = + { old | endDate = date } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + CreateSchoolYear -> + SchoolYear.handleCreateSchoolYear model + + SchoolYearCreated result -> + SchoolYear.handleSchoolYearCreated result model + + ActivateSchoolYear id -> + SchoolYear.handleActivateSchoolYear id model + + SchoolYearActivated result -> + SchoolYear.handleSchoolYearActivated result model + + DeleteSchoolYear id -> + SchoolYear.handleDeleteSchoolYear id model + + SchoolYearDeleted result -> + SchoolYear.handleSchoolYearDeleted result model + + -- PDF Download + DownloadYearlySummaryPDF -> + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token ) + + Nothing -> + ( model, Cmd.none ) + + YearlySummaryPDFReceived result -> + case result of + Ok pdfBytes -> + let + filename = + "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" + in + ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + -- Delete Confirmation + ConfirmDeleteUser userId -> + ( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) + + DeleteConfirmed confirmed -> + if confirmed then + case ( model.token, model.pendingDeleteId ) of + ( Just token, Just id ) -> + let + isTimeEntry = + List.any (\e -> e.id == id) model.timeEntries + in + if isTimeEntry then + ( model, Api.TimeEntry.deleteTimeEntry token id ) + + else + ( model, Api.User.deleteUser token id ) + + _ -> + ( model, Cmd.none ) + + else + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + -- Toasts + ShowToast message toastType -> + let + newToast = + { id = model.nextToastId + , message = message + , toastType = toastType + , dismissible = True + } + + dismissDelay = + case toastType of + ErrorToast -> + 8000 + + SuccessToast -> + 5000 + + InfoToast -> + 5000 + + WarningToast -> + 6000 + in + ( { model + | toasts = model.toasts ++ [ newToast ] + , nextToastId = model.nextToastId + 1 + } + , Task.perform (\_ -> AutoDismissToast newToast.id) + (Process.sleep dismissDelay) + ) + + DismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) + + AutoDismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) diff --git a/frontend/src/Update/UserUpdate.elm b/frontend/src/Update/UserUpdate.elm new file mode 100644 index 0000000..9fd4b85 --- /dev/null +++ b/frontend/src/Update/UserUpdate.elm @@ -0,0 +1,196 @@ +module Update.UserUpdate exposing + ( handleCreateUser + , handleDeleteUser + , handleEditUserWorkHours + , handleResetPasswordSaved + , handleResetUserPassword + , handleSaveResetPassword + , handleSaveUserWorkHours + , handleUserCreated + , handleUserDeleted + , handleUserWorkHoursSaved + , handleUsersReceived + ) + +import Api.User +import Http +import Task +import Types.Model exposing (Model, NewUser, ToastType(..), User) +import Types.Msg exposing (Msg(..)) + + +handleCreateUser : Model -> ( Model, Cmd Msg ) +handleCreateUser model = + case model.token of + Just token -> + ( model, Api.User.createUser token model.newUser ) + + Nothing -> + ( model, Cmd.none ) + + +handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserCreated result model = + case result of + Ok _ -> + let + emptyUser = + NewUser "" "" False + in + case model.token of + Just token -> + ( { model | newUser = emptyUser } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleDeleteUser : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteUser userId model = + case model.token of + Just token -> + ( model, Api.User.deleteUser token userId ) + + Nothing -> + ( model, Cmd.none ) + + +handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | pendingDeleteId = Nothing + , error = Nothing + , editingUserId = Nothing + , resetPasswordUserId = Nothing + } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + +handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg ) +handleUsersReceived result model = + case result of + Ok users -> + ( { model | users = users }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg ) +handleEditUserWorkHours userId model = + case List.filter (\u -> u.id == userId) model.users |> List.head of + Just user -> + ( { model + | editingUserId = Just userId + , editingUserWorkHours = String.fromFloat user.yearlyWorkHours + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + +handleSaveUserWorkHours : Model -> ( Model, Cmd Msg ) +handleSaveUserWorkHours model = + case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of + ( Just token, Just userId, Just hours ) -> + ( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) ) + + _ -> + ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) + + +handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserWorkHoursSaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingUserWorkHours = "" + , editingUserId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg ) +handleResetUserPassword userId model = + ( { model + | resetPasswordUserId = Just userId + , resetPasswordNew = "" + } + , Cmd.none + ) + + +handleSaveResetPassword : Model -> ( Model, Cmd Msg ) +handleSaveResetPassword model = + case model.resetPasswordUserId of + Just userId -> + case model.token of + Just token -> + ( model, Api.User.resetUserPassword token userId model.resetPasswordNew ) + + Nothing -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + +handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleResetPasswordSaved result model = + case result of + Ok _ -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + , error = Nothing + } + , Cmd.batch + [ case model.token of + Just token -> + Api.User.fetchUsers token + + Nothing -> + Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) + ] + ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Utils/DateUtils.elm b/frontend/src/Utils/DateUtils.elm new file mode 100644 index 0000000..1ea98dd --- /dev/null +++ b/frontend/src/Utils/DateUtils.elm @@ -0,0 +1,338 @@ +module Utils.DateUtils exposing + ( addDaysToDate + , getDateForWeekDay + , getDayOfWeek + , getDayOfYear + , getISOWeek + , getISOWeekFromPosix + , getWeekDateRange + , getYearWeekFromDate + , isLeapYear + , monthToInt + , nextWeek + , previousWeek + ) + +import Time + + +getISOWeekFromPosix : Time.Posix -> ( Int, Int ) +getISOWeekFromPosix time = + let + year = + Time.toYear Time.utc time + + month = + Time.toMonth Time.utc time |> monthToInt + + day = + Time.toDay Time.utc time + in + ( year, getISOWeek year month day ) + + +monthToInt : Time.Month -> Int +monthToInt month = + case month of + Time.Jan -> + 1 + + Time.Feb -> + 2 + + Time.Mar -> + 3 + + Time.Apr -> + 4 + + Time.May -> + 5 + + Time.Jun -> + 6 + + Time.Jul -> + 7 + + Time.Aug -> + 8 + + Time.Sep -> + 9 + + Time.Oct -> + 10 + + Time.Nov -> + 11 + + Time.Dec -> + 12 + + +getISOWeek : Int -> Int -> Int -> Int +getISOWeek year month day = + let + dayOfYear = + getDayOfYear year month day + + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1DayOfYear = + 4 - jan4DayOfWeek + + weekNum = + ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 + in + if weekNum < 1 then + 52 + + else if weekNum > 52 then + let + dec31DayOfWeek = + getDayOfWeek year 12 31 + + jan1DayOfWeek = + getDayOfWeek year 1 1 + in + if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then + weekNum + + else + 1 + + else + weekNum + + +getDayOfYear : Int -> Int -> Int -> Int +getDayOfYear year month day = + let + daysInMonth = + [ 31 + , if isLeapYear year then + 29 + + else + 28 + , 31 + , 30 + , 31 + , 30 + , 31 + , 31 + , 30 + , 31 + , 30 + , 31 + ] + + daysBefore = + List.take (month - 1) daysInMonth |> List.sum + in + daysBefore + day + + +isLeapYear : Int -> Bool +isLeapYear year = + (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) + + +getDayOfWeek : Int -> Int -> Int -> Int +getDayOfWeek year month day = + let + adjustedMonth = + if month < 3 then + month + 12 + + else + month + + adjustedYear = + if month < 3 then + year - 1 + + else + year + + q = + day + + m = + adjustedMonth + + k = + modBy 100 adjustedYear + + j = + adjustedYear // 100 + + h = + (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 + in + (h + 5) |> modBy 7 + + +getDateForWeekDay : Int -> Int -> Int -> String +getDateForWeekDay year week dayOfWeek = + let + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1Date = + 4 - jan4DayOfWeek + + targetDayOfYear = + mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek + + ( finalYear, finalMonth, finalDay ) = + if targetDayOfYear < 1 then + addDaysToDate (year - 1) 12 31 targetDayOfYear + + else + addDaysToDate year 1 targetDayOfYear 0 + in + String.fromInt finalYear + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalMonth) + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalDay) + + +addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) +addDaysToDate startYear startMonth startDay daysToAdd = + let + daysInMonth m y = + case m of + 1 -> + 31 + + 2 -> + if isLeapYear y then + 29 + + else + 28 + + 3 -> + 31 + + 4 -> + 30 + + 5 -> + 31 + + 6 -> + 30 + + 7 -> + 31 + + 8 -> + 31 + + 9 -> + 30 + + 10 -> + 31 + + 11 -> + 30 + + 12 -> + 31 + + _ -> + 0 + + helper y m d remaining = + if remaining == 0 then + ( y, m, d ) + + else if remaining > 0 then + let + daysInCurrentMonth = + daysInMonth m y + + daysLeftInMonth = + daysInCurrentMonth - d + in + if remaining <= daysLeftInMonth then + ( y, m, d + remaining ) + + else if m == 12 then + helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) + + else + helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) + + else if d + remaining >= 1 then + ( y, m, d + remaining ) + + else if m == 1 then + let + prevMonthDays = + daysInMonth 12 (y - 1) + in + helper (y - 1) 12 prevMonthDays (remaining + d) + + else + let + prevMonthDays = + daysInMonth (m - 1) y + in + helper y (m - 1) prevMonthDays (remaining + d) + in + helper startYear startMonth startDay daysToAdd + + +previousWeek : Int -> Int -> ( Int, Int ) +previousWeek year week = + if week == 1 then + ( year - 1, 52 ) + + else + ( year, week - 1 ) + + +nextWeek : Int -> Int -> ( Int, Int ) +nextWeek year week = + if week >= 52 then + ( year + 1, 1 ) + + else + ( year, week + 1 ) + + +getWeekDateRange : Int -> Int -> String +getWeekDateRange year week = + let + mondayDate = + getDateForWeekDay year week 0 + + fridayDate = + getDateForWeekDay year week 4 + in + mondayDate ++ " bis " ++ fridayDate + + +getYearWeekFromDate : String -> ( Int, Int ) +getYearWeekFromDate dateStr = + let + parts = + String.split "-" dateStr + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + in + ( year, getISOWeek year month day ) diff --git a/frontend/src/Utils/ErrorHandler.elm b/frontend/src/Utils/ErrorHandler.elm new file mode 100644 index 0000000..a9746e2 --- /dev/null +++ b/frontend/src/Utils/ErrorHandler.elm @@ -0,0 +1,42 @@ +module Utils.ErrorHandler exposing (handleApiError) + +import Api.Decoders exposing (apiErrorDecoder) +import Http +import Json.Decode as Decode +import Task +import Types.Model exposing (ToastType(..)) +import Types.Msg exposing (Msg(..)) + + +handleApiError : Http.Error -> Cmd Msg +handleApiError error = + let + message = + case error of + Http.BadBody body -> + case Decode.decodeString apiErrorDecoder body of + Ok apiErr -> + apiErr.message + + Err _ -> + "Ein Fehler ist aufgetreten" + + Http.BadStatus 401 -> + "Keine Berechtigung - bitte erneut anmelden" + + Http.BadStatus 403 -> + "Zugriff verweigert" + + Http.BadStatus 404 -> + "Ressource nicht gefunden" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Ein unerwarteter Fehler ist aufgetreten" + in + Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) diff --git a/frontend/src/Utils/Ports.elm b/frontend/src/Utils/Ports.elm new file mode 100644 index 0000000..f5b8dc2 --- /dev/null +++ b/frontend/src/Utils/Ports.elm @@ -0,0 +1,20 @@ +port module Utils.Ports exposing + ( confirmDelete + , confirmDeleteResponse + , removeToken + , saveToken + ) + +import Json.Encode as Encode + + +port saveToken : Encode.Value -> Cmd msg + + +port removeToken : () -> Cmd msg + + +port confirmDelete : String -> Cmd msg + + +port confirmDeleteResponse : (Bool -> msg) -> Sub msg diff --git a/frontend/src/Utils/TimeUtils.elm b/frontend/src/Utils/TimeUtils.elm new file mode 100644 index 0000000..2d74958 --- /dev/null +++ b/frontend/src/Utils/TimeUtils.elm @@ -0,0 +1,34 @@ +module Utils.TimeUtils exposing (calculateHours) + + +calculateHours : String -> String -> Float +calculateHours startTime endTime = + let + parseTime timeStr = + case String.split ":" timeStr of + [ h, m ] -> + (String.toFloat h |> Maybe.withDefault 0) + + ((String.toFloat m |> Maybe.withDefault 0) / 60) + + _ -> + 0 + + start = + parseTime startTime + + end = + parseTime endTime + in + if end > start then + end - start + + else if endTime == "manual" then + case String.toFloat startTime of + Just time -> + time + + Nothing -> + 0 + + else + 0 diff --git a/frontend/src/View/AdminDashboard.elm b/frontend/src/View/AdminDashboard.elm new file mode 100644 index 0000000..9afcfb5 --- /dev/null +++ b/frontend/src/View/AdminDashboard.elm @@ -0,0 +1,1165 @@ +module View.AdminDashboard exposing (viewAdminDashboard) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule, SchoolYear, TimeEntry, User, WeeklyHours, YearlyHoursSummary) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (AdminTab(..)) +import Utils.DateUtils exposing (getYearWeekFromDate) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewAdminDashboard : Model -> Html Msg +viewAdminDashboard model = + div [] + [ nav [ class "navbar is-danger" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarAdmin" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ div [ class "tabs is-boxed" ] + [ ul [] + [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] + [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] + , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] + [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] + , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] + [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] + , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ] + [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ] + ] + ] + , case model.activeTab of + ScheduleTab -> + viewScheduleTab model + + UsersTab -> + viewUsersTab model + + TimeEntriesTab -> + viewTimeEntriesTab model + + SchoolYearsTab -> + viewSchoolYearsTab model + ] + ] + ] + + +viewScheduleTab : Model -> Html Msg +viewScheduleTab model = + div [] + [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] + , viewScheduleForm model + , viewScheduleList model + ] + + +viewUsersTab : Model -> Html Msg +viewUsersTab model = + div [] + [ h2 [ class "title" ] [ text "Benutzer verwalten" ] + , viewUserForm model + , viewUserList model + ] + + +viewTimeEntriesTab : Model -> Html Msg +viewTimeEntriesTab model = + div [] + [ h2 [ class "title" ] [ text "Jahresübersicht" ] + , viewYearlyHoursSummary model + , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] + , viewAdminManualEntryForm model + , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] + , case model.editingTimeEntryId of + Just _ -> + viewTimeEntriesEditForm model + + Nothing -> + viewTimeEntriesListWithEdit model + ] + + +viewSchoolYearsTab : Model -> Html Msg +viewSchoolYearsTab model = + div [] + [ h2 [ class "title" ] [ text "Schuljahre verwalten" ] + , case model.activeSchoolYear of + Just schoolYear -> + div [ class "notification is-info is-light mb-4" ] + [ p [ class "has-text-weight-bold" ] + [ text ("Aktives Schuljahr: " ++ schoolYear.name) ] + , p [ class "is-size-7" ] + [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ] + ] + + Nothing -> + div [ class "notification is-warning is-light mb-4" ] + [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ] + , viewSchoolYearForm model + , viewSchoolYearsList model + ] + + +viewSchoolYearForm : Model -> Html Msg +viewSchoolYearForm model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "2024/2025" + , value model.newSchoolYear.name + , onInput UpdateNewSchoolYearName + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startdatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.startDate + , onInput UpdateNewSchoolYearStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Enddatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.endDate + , onInput UpdateNewSchoolYearEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchoolYear + , disabled + (String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + || model.isProcessing + ) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Schuljahr erstellen" + ] + ] + ] + ] + + +viewSchoolYearsList : Model -> Html Msg +viewSchoolYearsList model = + div [ class "box mt-4" ] + [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ] + , if List.isEmpty model.schoolYears then + p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Name" ] + , th [] [ text "Startdatum" ] + , th [] [ text "Enddatum" ] + , th [ class "has-text-centered" ] [ text "Status" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map viewSchoolYearRow model.schoolYears) + ] + ] + + +viewSchoolYearRow : SchoolYear -> Html Msg +viewSchoolYearRow schoolYear = + tr [] + [ td [] [ text schoolYear.name ] + , td [] [ text schoolYear.startDate ] + , td [] [ text schoolYear.endDate ] + , td [ class "has-text-centered" ] + [ if schoolYear.isActive then + span [ class "tag is-success" ] [ text "Aktiv" ] + + else + span [ class "tag is-light" ] [ text "Inaktiv" ] + ] + , td [ class "has-text-centered" ] + [ if not schoolYear.isActive then + button + [ class "button is-small is-info mr-2" + , onClick (ActivateSchoolYear schoolYear.id) + ] + [ text "Aktivieren" ] + + else + text "" + , button + [ class "button is-small is-danger" + , onClick (DeleteSchoolYear schoolYear.id) + ] + [ text "Löschen" ] + ] + ] + + +viewScheduleList : Model -> Html Msg +viewScheduleList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Tag" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [] [ text "Titel" ] + , th [] [ text "Aktion" ] + ] + ] + , tbody [] + (List.map viewScheduleRow model.schedules) + ] + ] + + +viewScheduleForm : Model -> Html Msg +viewScheduleForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Wochentag" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleDay + , disabled model.isProcessing + , value model.newSchedule.dayOfWeek + ] + [ option [ value "" ] [ text "Wochentag wählen" ] + , option [ value "0" ] [ text "Montag" ] + , option [ value "1" ] [ text "Dienstag" ] + , option [ value "2" ] [ text "Mittwoch" ] + , option [ value "3" ] [ text "Donnerstag" ] + , option [ value "4" ] [ text "Freitag" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.startTime + , onInput UpdateNewScheduleStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.endTime + , onInput UpdateNewScheduleEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleType + , value model.newSchedule.scheduleType + , disabled model.isProcessing + ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Titel" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "z.B. Mathematik" + , value model.newSchedule.title + , onInput UpdateNewScheduleTitle + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchedule + , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Hinzufügen" + ] + ] + ] + , if String.isEmpty model.newSchedule.dayOfWeek then + div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] + + else + text "" + ] + + +viewScheduleRow : Schedule -> Html Msg +viewScheduleRow schedule = + let + dayName = + case schedule.dayOfWeek of + 0 -> + "Montag" + + 1 -> + "Dienstag" + + 2 -> + "Mittwoch" + + 3 -> + "Donnerstag" + + 4 -> + "Freitag" + + _ -> + "Unbekannt" + + typeName = + if schedule.scheduleType == "break" then + "Pause" + + else + "Unterricht" + in + tr [] + [ td [] [ text dayName ] + , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , td [] [ text typeName ] + , td [] [ text schedule.title ] + , td [] + [ button + [ class "button is-small is-danger" + , onClick (DeleteSchedule schedule.id) + ] + [ text "Löschen" ] + ] + ] + + +viewUserForm : Model -> Html Msg +viewUserForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.newUser.username + , onInput UpdateNewUsername + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.newUser.password + , onInput UpdateNewPassword + ] + [] + ] + ] + ] + , div [ class "column is-narrow" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Admin" ] + , div [ class "control" ] + [ label [ class "checkbox" ] + [ input + [ type_ "checkbox" + , checked model.newUser.isAdmin + , onCheck UpdateNewUserAdmin + ] + [] + , text " Admin-Rechte" + ] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] + ] + ] + ] + + +viewUserList : Model -> Html Msg +viewUserList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Benutzer" ] + , if List.isEmpty model.users then + p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "ID" ] + , th [] [ text "Benutzername" ] + , th [] [ text "Rolle" ] + , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewUserRowWithActions model) model.users) + ] + ] + + +viewUserRowWithActions : Model -> User -> Html Msg +viewUserRowWithActions model user = + if model.editingUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "number" + , step "0.5" + , value model.editingUserWorkHours + , onInput UpdateEditUserWorkHours + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] + ] + ] + + else if model.resetPasswordUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "password" + , placeholder "Neues Passwort" + , value model.resetPasswordNew + , onInput UpdateResetPasswordNew + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + div [] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditUserWorkHours user.id) + ] + [ text "Arbeitszeit" ] + , button + [ class "button is-small is-warning mr-2" + , onClick (ResetUserPassword user.id) + ] + [ text "PW Reset" ] + , button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewUserRow : User -> Html Msg +viewUserRow user = + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + + +viewTimeEntriesList : Model -> Html Msg +viewTimeEntriesList model = + let + filteredEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + in + div [ class "box" ] + [ if List.isEmpty filteredEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithActions model) filteredEntries) + ] + ] + + +viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithActions model entry = + let + hours = + if entry.entryType == "lesson" then + 1.0 + + else + calculateHours entry.startTime entry.endTime + in + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [] + [ div [ class "buttons are-small" ] + [ button + [ class "button is-info is-small" + , onClick (StartEditingTimeEntry entry.id entry) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-danger is-small" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewTimeEntriesEditForm : Model -> Html Msg +viewTimeEntriesEditForm model = + div [ class "box has-background-warning-light" ] + [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-success" + , onClick SaveEditTimeEntry + ] + [ text "Speichern" ] + ] + , div [ class "control" ] + [ button + [ class "button is-light" + , onClick CancelEditTimeEntry + ] + [ text "Abbrechen" ] + ] + ] + , viewTimeEntriesListWithEdit model + ] + + +viewTimeEntriesListWithEdit : Model -> Html Msg +viewTimeEntriesListWithEdit model = + div [ class "box" ] + [ if List.isEmpty model.timeEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) + ] + ] + + +viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithEdit model entry = + let + hours = + calculateHours entry.startTime entry.endTime + + isEditing = + model.editingTimeEntryId == Just entry.id + in + if isEditing then + tr [] + [ td [] [ text entry.username ] + , td [] + [ input + [ class "input is-small" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + , td [] + [ div [ class "field is-grouped" ] + [ div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + , div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , td [] + [ div [ class "select is-small" ] + [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + , td [ class "has-text-right" ] [ text "" ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditTimeEntry entry.id) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-small is-danger" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + + +viewWeeklyHoursSummary : Model -> Html Msg +viewWeeklyHoursSummary model = + let + filteredHours = + List.filter + (\h -> h.week == model.currentWeek && h.year == model.currentYear) + model.weeklyHours + in + div [ class "box" ] + [ if List.isEmpty filteredHours then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Arbeitet" ] + , th [ class "has-text-right" ] [ text "Soll" ] + , th [ class "has-text-right" ] [ text "Verbleibend" ] + , th [] [ text "Fortschritt" ] + ] + ] + , tbody [] + (List.map viewWeeklyHoursRow filteredHours) + , tfoot [] + [ tr [ class "has-background-light" ] + [ th [] [ text "Gesamt" ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] + , th [] [ text "" ] + , th [] [ text "" ] + ] + ] + ] + ] + + +viewWeeklyHoursRow : WeeklyHours -> Html Msg +viewWeeklyHoursRow hours = + let + progressPercent = + Basics.min 100 (hours.totalHours / hours.targetHours * 100) + + progressColor = + if hours.totalHours >= hours.targetHours then + "is-success" + + else if hours.totalHours >= hours.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + tr [] + [ td [] [ text hours.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] + , td [] + [ progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [] + ] + ] + + +viewAdminManualEntryForm : Model -> Html Msg +viewAdminManualEntryForm model = + div [ class "box has-background-info-light" ] + [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] + , p [ class "help mb-3" ] + [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Mitarbeiter" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] + (option [ value "" ] [ text "-- Wählen --" ] + :: List.map + (\u -> + option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] + ) + model.users + ) + ] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.adminManualEntryForm.date + , onInput UpdateManualEntryDate + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "number" + , step "0.5" + , placeholder "z.B. 2.5 oder -1.0" + , value model.adminManualEntryForm.hours + , onInput UpdateManualEntryHours + ] + [] + ] + , p [ class "help" ] + [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-info" + , onClick SaveAdminTimeEntry + , disabled + (case model.adminManualEntryForm.selectedUserId of + Just _ -> + model.isProcessing || String.isEmpty model.adminManualEntryForm.hours + + Nothing -> + True + ) + ] + [ text "Eintrag erstellen" ] + ] + ] + ] + + +viewYearlyHoursSummary : Model -> Html Msg +viewYearlyHoursSummary model = + div [ class "box" ] + [ div [ class "level mb-4" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ a + [ class "button is-info" + , onClick DownloadYearlySummaryPDF + , disabled model.isProcessing + ] + [ span [ class "icon" ] + [ i [ class "fas fa-file-pdf" ] [] ] + , span [] + [ text + (if model.isProcessing then + "Wird erstellt..." + + else + "PDF exportieren" + ) + ] + ] + ] + ] + ] + , if List.isEmpty model.yearlyHoursSummary then + p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] + , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] + , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] + , th [ class "has-text-centered" ] [ text "Status" ] + ] + ] + , tbody [] + (List.map viewYearlyHourRow model.yearlyHoursSummary) + ] + ] + + +viewYearlyHourRow : YearlyHoursSummary -> Html Msg +viewYearlyHourRow summary = + let + statusClass = + if summary.remainingYearly > 0 then + "has-text-danger" + + else if abs summary.remainingYearly < 0.5 then + "has-text-success" + + else + "has-text-warning" + in + tr [] + [ td [] [ text summary.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] + , td [ class ("has-text-centered " ++ statusClass) ] + [ if summary.remainingYearly > 0 then + text ("Offen: " ++ String.fromFloat summary.remainingYearly) + + else if summary.remainingYearly < -0.5 then + text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) + + else + text "✓ Erfüllt" + ] + ] diff --git a/frontend/src/View/Components/Navigation.elm b/frontend/src/View/Components/Navigation.elm new file mode 100644 index 0000000..ba3895d --- /dev/null +++ b/frontend/src/View/Components/Navigation.elm @@ -0,0 +1,99 @@ +module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewWeekNavigation : Model -> Html Msg +viewWeekNavigation model = + let + dateRange = + case model.weekDates of + Just wd -> + wd.range + + Nothing -> + "Laden..." + in + div [ class "box" ] + [ nav [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick PreviousWeek + ] + [ span [ class "icon" ] + [ i [ class "fas fa-chevron-left" ] [] ] + , span [] [ text "Vorherige Woche" ] + ] + ] + ] + , div [ class "level-item" ] + [ div + [ style "display" "flex" + , style "flex-direction" "column" + , style "align-items" "center" + , style "gap" "0.5rem" + , style "min-width" "250px" + ] + [ p + [ class "heading" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text "Kalenderwoche" ] + , p + [ class "title is-3" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] + , p + [ class "subtitle is-6" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text dateRange ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick NextWeek + ] + [ span [] [ text "Nächste Woche" ] + , span [ class "icon" ] + [ i [ class "fas fa-chevron-right" ] [] ] + ] + ] + ] + ] + ] + + +viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg +viewDayMobile model dayName ( dayOfWeek, schedules ) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + + Nothing -> + "Laden..." + in + div [ class "box mb-4" ] + [ p [ class "has-text-weight-bold has-text-centered mb-3" ] + [ text (dayName ++ " - " ++ dateForDay) ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] diff --git a/frontend/src/View/Components/Schedule.elm b/frontend/src/View/Components/Schedule.elm new file mode 100644 index 0000000..57730bb --- /dev/null +++ b/frontend/src/View/Components/Schedule.elm @@ -0,0 +1,76 @@ +module View.Components.Schedule exposing (viewScheduleItemWithDay) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) + + +viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg +viewScheduleItemWithDay model dayOfWeek schedule = + let + isSelected = + List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries + + isClickable = + (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing + + boxClass = + if isSelected then + "box has-background-success-light" + + else if isClickable then + "box has-background-white" + + else + "box has-background-light" + + typeText = + if schedule.scheduleType == "break" then + " (Pause)" + + else + "" + + cursorStyle = + if isClickable then + "pointer" + + else + "not-allowed" + + opacity = + if isClickable || isSelected then + "1" + + else + "0.6" + in + div + [ class boxClass + , onClick + (if isClickable then + ToggleScheduleSelection schedule.id dayOfWeek + + else + FetchSchedules + ) + , style "cursor" cursorStyle + , style "margin-bottom" "0.5rem" + , style "padding" "0.75rem" + , style "opacity" opacity + , style "transition" "all 0.2s ease" + , style "border" + (if isClickable && not isSelected then + "2px solid transparent" + + else + "2px solid currentColor" + ) + ] + [ p [ class "has-text-weight-bold is-size-7" ] + [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , p [ class "is-size-7" ] + [ text (schedule.title ++ typeText) ] + ] diff --git a/frontend/src/View/Components/Toast.elm b/frontend/src/View/Components/Toast.elm new file mode 100644 index 0000000..e55d2fe --- /dev/null +++ b/frontend/src/View/Components/Toast.elm @@ -0,0 +1,66 @@ +module View.Components.Toast exposing (viewToasts) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule, Toast, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewToasts : List Toast -> Html Msg +viewToasts toasts = + div [ class "toast-container" ] + (List.map viewToast toasts) + + +viewToast : Toast -> Html Msg +viewToast toast = + let + toastClass = + case toast.toastType of + ErrorToast -> + "toast-error" + + SuccessToast -> + "toast-success" + + InfoToast -> + "toast-info" + + WarningToast -> + "toast-warning" + + icon = + case toast.toastType of + ErrorToast -> + "fas fa-exclamation-circle" + + SuccessToast -> + "fas fa-check-circle" + + InfoToast -> + "fas fa-info-circle" + + WarningToast -> + "fas fa-exclamation-triangle" + in + div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] + [ div [ class "toast-content" ] + [ span [ class "toast-icon" ] + [ i [ class icon ] [] ] + , span [ class "toast-message" ] [ text toast.message ] + ] + , if toast.dismissible then + button + [ class "toast-close" + , onClick (DismissToast toast.id) + , attribute "aria-label" "Schließen" + ] + [ i [ class "fas fa-times" ] [] ] + + else + text "" + ] diff --git a/frontend/src/View/Login.elm b/frontend/src/View/Login.elm new file mode 100644 index 0000000..9ed2485 --- /dev/null +++ b/frontend/src/View/Login.elm @@ -0,0 +1,57 @@ +module View.Login exposing (viewLogin) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model) +import Types.Msg exposing (Msg(..)) + + +viewLogin : Model -> Html Msg +viewLogin model = + section [ class "section" ] + [ div [ class "container" ] + [ div [ class "columns is-centered" ] + [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] + [ div [ class "box" ] + [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.username + , onInput UpdateUsername + ] + [] + ] + ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.password + , onInput UpdatePassword + ] + [] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-fullwidth" + , onClick Login + ] + [ text "Anmelden" ] + ] + ] + ] + ] + ] + ] + ] diff --git a/frontend/src/View/UserDashboard.elm b/frontend/src/View/UserDashboard.elm new file mode 100644 index 0000000..60fac13 --- /dev/null +++ b/frontend/src/View/UserDashboard.elm @@ -0,0 +1,338 @@ +module View.UserDashboard exposing (viewUserDashboard) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewUserDashboard : Model -> Html Msg +viewUserDashboard model = + div [] + [ nav [ class "navbar is-primary" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "role" "navigation" + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarUser" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ viewWeekNavigation model + , h2 [ class "title" ] [ text "Stundenplan" ] + , if model.hasEntriesForCurrentWeek && not model.weekEditMode then + div [ class "notification is-success" ] + [ div [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ span [ class "icon" ] + [ i [ class "fas fa-check-circle" ] [] ] + , span [] [ text "Diese Woche wurde bereits erfasst" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-warning" + , onClick EnableEditMode + , disabled model.isProcessing + ] + [ text "Bearbeiten" ] + ] + ] + ] + ] + + else if model.weekEditMode then + div [ class "notification is-warning" ] + [ div [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ span [ class "icon" ] + [ i [ class "fas fa-edit" ] [] ] + , span [] [ text "Bearbeitungsmodus aktiv" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-danger is-small mr-2" + , onClick DeleteWeekEntries + , disabled model.isProcessing + ] + [ text "Einträge löschen" ] + , button + [ class "button is-light is-small" + , onClick DisableEditMode + ] + [ text "Abbrechen" ] + ] + ] + ] + ] + + else + div [ class "notification is-info is-light" ] + [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] + , viewScheduleGridWithWeek model + , if not model.hasEntriesForCurrentWeek || model.weekEditMode then + div [ class "field mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-large is-fullwidth" + , onClick SaveTimeEntries + , disabled (List.isEmpty model.selectedEntries || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text + (if model.weekEditMode then + "Änderungen speichern" + + else + "Speichern" + ) + ] + ] + ] + + else + text "" + , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] + , viewUserYearlyTotal model + ] + ] + ] + + +viewScheduleGridWithWeek : Model -> Html Msg +viewScheduleGridWithWeek model = + let + days = + [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ] + + groupedSchedules = + List.range 0 4 + |> List.map + (\day -> + ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) + ) + in + div [] + [ div [ class "is-hidden-mobile" ] + [ div [ class "table-container" ] + [ table [ class "table is-bordered is-fullwidth" ] + [ thead [] + [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) + ] + , tbody [] + [ tr [] + (List.map (viewDayColumnWithWeek model) groupedSchedules) + ] + ] + ] + ] + , div [ class "is-hidden-tablet" ] + (List.map2 (viewDayMobile model) days groupedSchedules) + ] + + +viewUserYearlyTotal : Model -> Html Msg +viewUserYearlyTotal model = + let + yearlyTotal = + model.timeEntries + |> List.map + (\entry -> + if entry.entryType == "lesson" then + 1.0 + + else + Utils.TimeUtils.calculateHours entry.startTime entry.endTime + ) + |> List.sum + + userTarget = + List.filter (\u -> not u.isAdmin) model.users + |> List.head + |> Maybe.map .yearlyWorkHours + |> Maybe.withDefault 60 + + remaining = + userTarget - yearlyTotal + + progressPercent = + Basics.min 100 (yearlyTotal / userTarget * 100) + + progressColor = + if remaining <= 0 then + "is-success" + + else if yearlyTotal >= userTarget * 0.8 then + "is-info" + + else + "is-warning" + in + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ p [ class "heading" ] [ text "Jahresenziel" ] + , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Geleistete Stunden" ] + , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Restliche Stunden" ] + , p + [ class + ("title is-4 " + ++ (if remaining <= 0 then + "has-text-success" + + else + "has-text-warning" + ) + ) + ] + [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ] + ] + ] + , progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [ text (String.fromFloat progressPercent ++ "%") ] + ] + + +viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg +viewDayColumnWithWeek model ( dayOfWeek, schedules ) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + + Nothing -> + "Laden..." + in + td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] + [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] + [ text dateForDay ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] + + +viewUserWeeklySummary : Model -> Html Msg +viewUserWeeklySummary model = + case model.userWeeklySummary of + Just summary -> + let + progressPercent = + Basics.min 100 (summary.totalHours / summary.targetHours * 100) + + progressColor = + if summary.totalHours >= summary.targetHours then + "is-success" + + else if summary.totalHours >= summary.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] + , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] + , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Verbleibend" ] + , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ] + [ text (String.fromFloat summary.remainingHours ++ " Std.") ] + , if summary.remainingHours < 0 then + p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] + + else + p [ class "subtitle is-6" ] [ text "" ] + ] + ] + , progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [ text (String.fromFloat progressPercent ++ "%") ] + ] + + Nothing -> + div [ class "box" ] + [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] + ] diff --git a/frontend/src/View/View.elm b/frontend/src/View/View.elm new file mode 100644 index 0000000..c16d910 --- /dev/null +++ b/frontend/src/View/View.elm @@ -0,0 +1,29 @@ +module View.View exposing (view) + +import Html exposing (Html, div) +import Html.Attributes exposing (class) +import Types.Model exposing (Model) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (Page(..)) +import View.AdminDashboard exposing (viewAdminDashboard) +import View.Components.Toast exposing (viewToasts) +import View.Login exposing (viewLogin) +import View.UserDashboard exposing (viewUserDashboard) + + +view : Model -> Html Msg +view model = + div [ class "app-container" ] + [ viewToasts model.toasts + , div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model + + UserDashboard -> + viewUserDashboard model + + AdminDashboard -> + viewAdminDashboard model + ] + ]