From 9c259567112f7226758aaf303f0244d62a413267 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 5 Nov 2025 17:09:37 +0100 Subject: [PATCH] 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 =