From 5001cc11473e2e9399ed4fa277b6ce526f783651 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 5 Nov 2025 14:15:38 +0100 Subject: [PATCH] feat: update all and add all features for version 1.0 clean up in codebase needed --- Dockerfile | 56 +++ backend/database.go | 394 +++++++++++++--- backend/handlers.go | 164 ++++++- backend/main.go | 5 + backend/models.go | 45 +- docker-compose.yml | 37 ++ frontend/public/index.html | 36 ++ frontend/src/Main.elm | 889 +++++++++++++++++++++++++++++++++++-- 8 files changed, 1497 insertions(+), 129 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/public/index.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..adae492 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# Build stage for Elm frontend +FROM node:25-alpine AS elm-build + +WORKDIR /frontend + +# Install Elm +RUN npm install -g elm@latest-0.19.1 + +# Copy Elm files +COPY frontend/elm.json . +COPY frontend/src ./src + +# Build Elm app +RUN elm make src/Main.elm --optimize --output=elm.js + +# Build stage for Go backend +FROM golang:1.25.3-alpine AS go-build + +WORKDIR /app + +# Copy go mod files +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +# Copy backend source +COPY backend/ ./ + +# Build Go binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /root/ + +# Copy Go binary from build stage +COPY --from=go-build /app/main . + +# Create static directory +RUN mkdir -p /root/static + +# Copy Elm build artifacts +COPY --from=elm-build /frontend/elm.js /root/static/ +COPY frontend/public/index.html /root/static/ + +# Create volume for database +VOLUME ["/data"] + +ENV PORT=8080 +ENV DB_PATH=/data/timetracking.db + +EXPOSE 8080 + +CMD ["./main"] diff --git a/backend/database.go b/backend/database.go index 8caacca..32ffd22 100644 --- a/backend/database.go +++ b/backend/database.go @@ -4,6 +4,10 @@ import ( "database/sql" "fmt" "log" + "sort" + "strconv" + "strings" + "time" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" @@ -26,31 +30,32 @@ 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 - )`, + 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 + )`, `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 - )`, + 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) - )`, + 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) + )`, } for _, query := range queries { @@ -61,33 +66,90 @@ func createTables(db *sql.DB) { 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, + INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours) + VALUES (?, ?, ?, ?, ?)`, + 1, "admin", string(hash), true, 40.0, ) if err != nil { log.Fatal(err) } } +// 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) +// )`, +// } + +// 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) +// } +// } + 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) + 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) 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) +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) + 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) return err } func GetAllUsers(db *sql.DB) ([]User, error) { - rows, err := db.Query("SELECT id, username, is_admin FROM users") + rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users") if err != nil { return nil, err } @@ -96,7 +158,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); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.WeeklyHours); err != nil { continue } users = append(users, u) @@ -104,6 +166,59 @@ 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) + return err +} + +func ResetUserPassword(db *sql.DB, userID int, hashedPassword string) error { + _, err := db.Exec("UPDATE users SET password = ? WHERE id = ?", hashedPassword, userID) + return err +} + +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 CreateSchedule(db *sql.DB, schedule *Schedule) error { _, err := db.Exec("INSERT INTO schedules (day_of_week, start_time, end_time, type, title) VALUES (?, ?, ?, ?, ?)", schedule.DayOfWeek, schedule.StartTime, schedule.EndTime, schedule.Type, schedule.Title) @@ -133,6 +248,17 @@ func DeleteSchedule(db *sql.DB, id int) error { return err } +func UpdateTimeEntry(db *sql.DB, entryID int, date, startTime, endTime, entryType string) error { + _, err := db.Exec("UPDATE time_entries SET date = ?, start_time = ?, end_time = ?, type = ? WHERE id = ?", + date, startTime, endTime, entryType, entryID) + return err +} + +func DeleteTimeEntry(db *sql.DB, entryID int) error { + _, err := db.Exec("DELETE FROM time_entries WHERE id = ?", entryID) + return err +} + func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)", entry.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) @@ -188,53 +314,189 @@ func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { rows, err := db.Query(` - SELECT - te.user_id, - u.username, - -- ISO 8601 Wochennummer berechnen - CASE - WHEN strftime('%j', date(te.date, '-' || ((strftime('%w', te.date) + 6) % 7) || ' days')) <= '3' - THEN CAST(strftime('%Y', date(te.date, '-' || ((strftime('%w', te.date) + 6) % 7) || ' days', '-4 days')) AS INTEGER) - ELSE CAST(strftime('%Y', te.date) AS INTEGER) - END as year, - CASE - WHEN strftime('%j', date(te.date, '-' || ((strftime('%w', te.date) + 6) % 7) || ' days')) <= '3' - THEN CAST((strftime('%j', date(te.date, '-' || ((strftime('%w', te.date) + 6) % 7) || ' days', '-4 days')) - 1) / 7 AS INTEGER) + 53 - ELSE CAST((strftime('%j', date(te.date, '-' || ((strftime('%w', te.date) + 6) % 7) || ' days')) - 1) / 7 AS INTEGER) + 1 - END as week, - SUM( - (CAST(substr(te.end_time, 1, 2) AS REAL) + CAST(substr(te.end_time, 4, 2) AS REAL) / 60.0) - - (CAST(substr(te.start_time, 1, 2) AS REAL) + CAST(substr(te.start_time, 4, 2) AS REAL) / 60.0) - ) as total_hours - FROM time_entries te - JOIN users u ON te.user_id = u.id - GROUP BY te.user_id, u.username, week, year - ORDER BY year DESC, week DESC, u.username - `) + 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 + `) if err != nil { return nil, err } defer rows.Close() - var hours []WeeklyHours + hoursMap := make(map[string]*WeeklyHours) + for rows.Next() { - var h WeeklyHours - if err := rows.Scan(&h.UserID, &h.Username, &h.Year, &h.Week, &h.TotalHours); err != nil { + var userID int + var username, dateStr, startTime, endTime, entryType string + var expectedWeeklyHours float64 + + if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &expectedWeeklyHours); err != nil { continue } - hours = append(hours, h) + + t, err := time.Parse("2006-01-02", dateStr) + if err != nil { + continue + } + + year, week := t.ISOWeek() + + var hours float64 + if entryType == "lesson" { + hours = 1.0 + } else { + 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, + ExpectedHours: expectedWeeklyHours, + RemainingHours: expectedWeeklyHours - hours, + } + } } - return hours, nil + + for _, h := range hoursMap { + h.RemainingHours = h.ExpectedHours - h.TotalHours + } + + 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 DeleteUser(db *sql.DB, id int) error { - if id == 1 { - return fmt.Errorf("cannot delete admin user") +// 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, ":") + if len(parts) != 2 { + return 0 + } + + hours, err1 := strconv.ParseFloat(parts[0], 64) + minutes, err2 := strconv.ParseFloat(parts[1], 64) + + if err1 != nil || err2 != nil { + return 0 + } + + return hours + (minutes / 60.0) } - _, err := db.Exec("DELETE FROM users WHERE id = ?", id) - return err + + start := parseTime(startTime) + end := parseTime(endTime) + + if end > start { + return end - start + } + 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/handlers.go b/backend/handlers.go index a5a9621..14fd4cd 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -79,24 +79,24 @@ func (app *App) DeleteScheduleHandler(c echo.Context) error { return c.NoContent(http.StatusOK) } -// 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") - } +// // 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") +// } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password") - } +// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) +// if err != nil { +// return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password") +// } - if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } +// if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { +// return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) +// } - return c.JSON(http.StatusCreated, map[string]string{"message": "user created"}) -} +// return c.JSON(http.StatusCreated, map[string]string{"message": "user created"}) +// } func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) @@ -308,3 +308,137 @@ func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) } + +func (app *App) UpdateUserHandler(c echo.Context) error { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + } + + var req UpdateUserRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + if err := UpdateUser(app.DB, userID, req.WeeklyHours); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusOK) +} + +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") + } + + var req ResetPasswordRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + } + + if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusOK) +} + +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") + } + + var req UpdateTimeEntryRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusOK) +} + +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") + } + + if err := DeleteTimeEntry(app.DB, entryID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusOK) +} + +func (app *App) GetMyWeeklySummaryHandler(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, + }) +} + +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()) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + } + + if req.WeeklyHours == 0 { + req.WeeklyHours = 40.0 + } + + if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.WeeklyHours); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.NoContent(http.StatusCreated) +} diff --git a/backend/main.go b/backend/main.go index 68b90b3..762ed67 100644 --- a/backend/main.go +++ b/backend/main.go @@ -44,6 +44,7 @@ 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) } admin := e.Group("/api/admin") @@ -57,6 +58,10 @@ func main() { admin.DELETE("/users/delete", app.DeleteUserHandler) 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("/time-entries/:id", app.UpdateTimeEntryHandler) + admin.DELETE("/time-entries/:id", app.DeleteTimeEntryHandler) } e.Static("/", "./static") diff --git a/backend/models.go b/backend/models.go index cd2f7b6..085c4ef 100644 --- a/backend/models.go +++ b/backend/models.go @@ -15,18 +15,21 @@ 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"` + 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"` } type User struct { - ID int `json:"id"` - Username string `json:"username"` - Password string `json:"-"` - IsAdmin bool `json:"is_admin"` + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"-"` + IsAdmin bool `json:"is_admin"` + WeeklyHours float64 `json:"weekly_hours"` } type Schedule struct { @@ -50,12 +53,28 @@ type LoginResponse struct { } type CreateUserRequest struct { - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required,min=6"` - IsAdmin bool `json:"is_admin"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required,min=6"` + IsAdmin bool `json:"is_admin"` + WeeklyHours float64 `json:"weekly_hours"` +} + +type UpdateUserRequest struct { + Username string `json:"username"` + WeeklyHours float64 `json:"weekly_hours"` +} + +type ResetPasswordRequest struct { + NewPassword string `json:"new_password" validate:"required,min=6"` +} + +type UpdateTimeEntryRequest struct { + Date string `json:"date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Type string `json:"type"` } -// Claims für JWT type Claims struct { UserID int `json:"user_id"` Username string `json:"username"` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16d47f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# 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: . + container_name: school-timetracking + ports: + - "8080:8080" + environment: + - PORT=8080 + - DB_PATH=/data/timetracking.db + volumes: + - timetracking-data:/data + restart: unless-stopped + networks: + - timetracking-net + +volumes: + timetracking-data: + driver: local + +networks: + timetracking-net: + driver: bridge + diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..426d625 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,36 @@ + + + + + + Schulzeit Erfassung + + + + +
+ + + + diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 9b4bf87..3eb4b28 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -17,6 +17,8 @@ import Dict exposing (Dict) port saveToken : String -> Cmd msg port removeToken : () -> Cmd msg +port confirmDelete : String -> Cmd msg +port confirmDeleteResponse : (Bool -> msg) -> Sub msg -- MAIN @@ -46,7 +48,7 @@ type alias Model = , selectedEntries : List SelectedEntry , currentWeek : Int , currentYear : Int - , weekDates : Maybe WeekDates -- NEU: Backend liefert Daten + , weekDates : Maybe WeekDates , currentTime : Time.Posix , zone : Time.Zone , newSchedule : NewSchedule @@ -54,6 +56,17 @@ 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 + , userWorkHoursInput : String + , userPasswordInput : String } type Page @@ -79,6 +92,7 @@ type alias User = { id : Int , username : String , isAdmin : Bool + , weeklyWorkHours : Float -- NEU } type alias TimeEntry = @@ -92,14 +106,6 @@ type alias TimeEntry = , endTime : String } -type alias WeeklyHours = - { userId : Int - , username : String - , week : Int - , year : Int - , totalHours : Float - } - type alias SelectedEntry = { scheduleId : Int , dayOfWeek : Int @@ -126,6 +132,34 @@ type alias WeekDates = , 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 + } + init : Maybe String -> (Model, Cmd Msg) init storedToken = let @@ -151,6 +185,17 @@ 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 + , userWorkHoursInput = "" + , userPasswordInput = "" } cmd = @@ -214,6 +259,42 @@ 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 + | StartEditingTimeEntry Int TimeEntry + | CancelEditingTimeEntry + | UpdateEditingTimeEntryDate String + | UpdateEditingTimeEntryStartTime String + | UpdateEditingTimeEntryEndTime String + | UpdateEditingTimeEntryType String + | SaveEditingTimeEntry + | SelectUserForManagement Int + | UpdateUserWorkHours String + | UpdateUserPassword String + | SaveUserPassword + | UserPasswordSaved (Result Http.Error ()) update : Msg -> Model -> (Model, Cmd Msg) update msg model = @@ -247,9 +328,15 @@ update msg model = [ fetchMyTimeEntries result.token , fetchWeekDates result.token year week , checkWeekHasEntries result.token year week + , fetchMyWeeklySummary result.token year week -- NEU! ] else - Cmd.none + Cmd.batch + [ fetchMyTimeEntries result.token + , fetchWeekDates result.token year week + , checkWeekHasEntries result.token year week + , fetchMyWeeklySummary result.token year week -- NEU! + ] ]) LoginResponse (Err _) -> @@ -300,7 +387,10 @@ update msg model = , error = Nothing , weekEditMode = False , hasEntriesForCurrentWeek = True - }, fetchMyTimeEntries token) + }, Cmd.batch + [ fetchMyTimeEntries token + , fetchMyWeeklySummary token model.currentYear model.currentWeek -- NEU! + ]) Nothing -> (model, Cmd.none) @@ -321,6 +411,7 @@ update msg model = Cmd.batch [ fetchWeekDates token newYear newWeek , checkWeekHasEntries token newYear newWeek + , fetchMyWeeklySummary token newYear newWeek -- NEU! ] Nothing -> Cmd.none @@ -340,6 +431,7 @@ update msg model = Cmd.batch [ fetchWeekDates token newYear newWeek , checkWeekHasEntries token newYear newWeek + , fetchMyWeeklySummary token newYear newWeek -- NEU! ] Nothing -> Cmd.none @@ -377,11 +469,12 @@ update msg model = cmds = case model.token of Just token -> - if model.page == UserDashboard then + if model.page == UserDashboard || model.page == LoginPage then Cmd.batch [ checkWeekHasEntries token year week , fetchWeekDates token year week , fetchMyTimeEntries token + , fetchMyWeeklySummary token year week -- NEU! ] else Cmd.none @@ -588,12 +681,15 @@ update msg model = UserDeleted (Ok _) -> case model.token of Just token -> - (model, fetchUsers token) + ({ model + | pendingDeleteId = Nothing + , error = Nothing + }, fetchUsers token) Nothing -> (model, Cmd.none) UserDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen des Benutzers" }, Cmd.none) + ({ model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing}, Cmd.none) FetchUsers -> case model.token of @@ -660,13 +756,306 @@ update msg model = WeeklyHoursReceived (Err _) -> ({ model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none) + FetchMyWeeklySummary -> + case model.token of + Just token -> + (model, fetchMyWeeklySummary token model.currentYear model.currentWeek) + Nothing -> + (model, Cmd.none) + + MyWeeklySummaryReceived (Ok summary) -> + ({ model | userWeeklySummary = Just summary }, Cmd.none) + + MyWeeklySummaryReceived (Err _) -> + ({ model | userWeeklySummary = Nothing }, Cmd.none) + + EditTimeEntry entryId -> + case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of + Just entry -> + ({ model + | editingTimeEntryId = Just entryId + , editingTimeEntry = + { entryId = entryId + , date = entry.date + , startTime = entry.startTime + , endTime = entry.endTime + , entryType = entry.entryType + } + }, Cmd.none) + Nothing -> + (model, Cmd.none) + + CancelEditTimeEntry -> + ({ model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + }, Cmd.none) + + UpdateEditTimeEntryDate date -> + let + old = model.editingTimeEntry + new = { old | date = date } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + UpdateEditTimeEntryStartTime time -> + let + old = model.editingTimeEntry + new = { old | startTime = time } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + UpdateEditTimeEntryEndTime time -> + let + old = model.editingTimeEntry + new = { old | endTime = time } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + UpdateEditTimeEntryType entryType -> + let + old = model.editingTimeEntry + new = { old | entryType = entryType } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + SaveEditTimeEntry -> + case model.token of + Just token -> + (model, updateTimeEntry token model.editingTimeEntry) + Nothing -> + (model, Cmd.none) + + TimeEntryDeleted (Ok _) -> + case model.token of + Just token -> + ({ model + | editingTimeEntryId = Nothing + , pendingDeleteId = Nothing + , error = Nothing + }, fetchAllTimeEntries token) + Nothing -> + (model, Cmd.none) + + TimeEntryDeleted (Err _) -> + ({ model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing}, Cmd.none) + + EditUserWorkHours userId -> + case List.filter (\u -> u.id == userId) model.users |> List.head of + Just user -> + ({ model + | editingUserId = Just userId + , editingUserWorkHours = String.fromFloat user.weeklyWorkHours + }, Cmd.none) + Nothing -> + (model, Cmd.none) + + CancelEditUserWorkHours -> + ({ model + | editingUserId = Nothing + , editingUserWorkHours = "" + }, Cmd.none) + + UpdateEditUserWorkHours hours -> + ({ model | editingUserWorkHours = hours }, Cmd.none) + + ResetUserPassword userId -> + ({ model + | resetPasswordUserId = Just userId + , resetPasswordNew = "" + }, Cmd.none) + + CancelResetPassword -> + ({ model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + }, Cmd.none) + + UpdateResetPasswordNew password -> + ({ model | resetPasswordNew = password }, Cmd.none) + + SaveResetPassword -> + case model.resetPasswordUserId of + Just userId -> + case model.token of + Just token -> + (model, resetUserPassword token userId model.resetPasswordNew) + Nothing -> + (model, Cmd.none) + Nothing -> + (model, Cmd.none) + + ResetPasswordSaved (Ok _) -> + ({ model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + , error = Just "Passwort erfolgreich zurückgesetzt" + }, case model.token of + Just token -> + fetchUsers token + Nothing -> + Cmd.none + ) + + ResetPasswordSaved (Err _) -> + ({ model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none) + StartEditingTimeEntry entryId entry -> + ({ model + | editingTimeEntryId = Just entryId + , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType + }, Cmd.none) + + CancelEditingTimeEntry -> + ({ model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + }, Cmd.none) + + UpdateEditingTimeEntryDate date -> + let + old = model.editingTimeEntry + new = { old | date = date } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + UpdateEditingTimeEntryStartTime time -> + let + old = model.editingTimeEntry + new = { old | startTime = time } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + UpdateEditingTimeEntryEndTime time -> + let + old = model.editingTimeEntry + new = { old | endTime = time } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + UpdateEditingTimeEntryType entryType -> + let + old = model.editingTimeEntry + new = { old | entryType = entryType } + in + ({ model | editingTimeEntry = new }, Cmd.none) + + SaveEditingTimeEntry -> + case (model.token, model.editingTimeEntryId) of + (Just token, Just entryId) -> + (model, updateTimeEntry token model.editingTimeEntry) + _ -> + (model, Cmd.none) + + TimeEntrySaved (Ok _) -> + case model.token of + Just token -> + ({ model + | editingTimeEntryId = Nothing + , pendingDeleteId = Nothing + , error = Nothing + }, fetchAllTimeEntries token) + Nothing -> + (model, Cmd.none) + + TimeEntrySaved (Err _) -> + ({ model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none) + + ConfirmDeleteTimeEntry entryId -> + ({ model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?") + + ConfirmDeleteUser userId -> + ({ model | pendingDeleteId = Just userId }, confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?") + + DeleteConfirmed confirmed -> + if confirmed then + case (model.token, model.pendingDeleteId) of + (Just token, Just id) -> + let + isTimeEntry = List.any (\e -> e.id == id) model.timeEntries + in + if isTimeEntry then + (model, deleteTimeEntry token id) + else + (model, deleteUser token id) + _ -> + (model, Cmd.none) + else + ({ model | pendingDeleteId = Nothing }, Cmd.none) + + SelectUserForManagement userId -> + ({ model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none) + + UpdateUserWorkHours input -> + ({ model | userWorkHoursInput = input }, Cmd.none) + + SaveUserWorkHours -> + case (model.token, model.editingUserId, String.toFloat model.editingUserWorkHours) of -- ← Änderungen! + (Just token, Just userId, Just hours) -> + (model, updateUserWorkHours token userId (String.fromFloat hours)) + _ -> + ({ model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none) + + UserWorkHoursSaved (Ok _) -> + case model.token of + Just token -> + ({ model + | editingUserWorkHours = "" -- ← Änderung + , editingUserId = Nothing -- ← Änderung + , 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) + + UpdateUserPassword input -> + ({ model | userPasswordInput = input }, Cmd.none) + + SaveUserPassword -> + case (model.token, model.selectedUserId) of + (Just token, Just userId) -> + if String.length model.userPasswordInput > 0 then + (model, resetUserPassword token userId model.userPasswordInput) + else + ({ model | error = Just "Passwort erforderlich" }, Cmd.none) + + _ -> + ({ model | error = Just "Passwort erforderlich" }, Cmd.none) + + UserPasswordSaved (Ok _) -> + ({ model + | userPasswordInput = "" + , selectedUserId = Nothing + , error = Nothing + }, Cmd.none) + + UserPasswordSaved (Err _) -> + ({ model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none) + -- SUBSCRIPTIONS subscriptions : Model -> Sub Msg subscriptions model = - Sub.none + confirmDeleteResponse DeleteConfirmed -- NEU -- HELPER FUNCTIONS @@ -1017,6 +1406,8 @@ viewUserDashboard model = ] else text "" + , h3 [ class "subtitle mt-6" ] [ text "Wochenzusammenfassung" ] + , viewUserWeeklySummary model , case model.error of Just err -> @@ -1027,6 +1418,50 @@ viewUserDashboard model = ] ] +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..." ] + ] + viewAdminDashboard : Model -> Html Msg viewAdminDashboard model = div [] @@ -1091,9 +1526,192 @@ viewTimeEntriesTab model = , h2 [ class "title" ] [ text "Wochenstunden Übersicht" ] , viewWeeklyHoursSummary model , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] - , viewTimeEntriesList model + + , case model.editingTimeEntryId of + Just _ -> + viewTimeEntriesEditForm model + Nothing -> + viewTimeEntriesListWithEdit model ] +-- Separate Edit Form View +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 = + 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 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) filteredEntries) + ] + ] + +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 + -- Edit-Modus + 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 @@ -1396,20 +2014,92 @@ viewUserList : Model -> Html Msg viewUserList model = div [ class "box" ] [ h3 [ class "subtitle" ] [ text "Benutzer" ] - , table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "ID" ] - , th [] [ text "Benutzername" ] - , th [] [ text "Rolle" ] - , th [] [ text "Aktion" ] + , 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/Woche" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] ] + , tbody [] + (List.map (viewUserRowWithActions model) model.users) ] - , tbody [] - (List.map viewUserRow model.users) - ] ] +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 ] + , 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.weeklyWorkHours ++ " 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 [] @@ -1442,7 +2132,10 @@ viewWeeklyHoursSummary model = [ thead [] [ tr [] [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Gesamtstunden" ] + , 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 [] @@ -1452,6 +2145,10 @@ viewWeeklyHoursSummary model = [ 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 "" ] ] ] ] @@ -1459,9 +2156,28 @@ viewWeeklyHoursSummary model = 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 @@ -1491,14 +2207,18 @@ viewTimeEntriesList model = ] ] , tbody [] - (List.map viewTimeEntryRow filteredEntries) + (List.map (viewTimeEntryRowWithActions model) filteredEntries) -- KORRIGIERT: model übergeben ] ] -viewTimeEntryRow : TimeEntry -> Html Msg -viewTimeEntryRow entry = +viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithActions model entry = let - hours = calculateHours entry.startTime entry.endTime + hours = + if entry.entryType == "lesson" then + 1.0 + else + calculateHours entry.startTime entry.endTime in tr [] [ td [] [ text entry.username ] @@ -1506,9 +2226,20 @@ viewTimeEntryRow entry = , 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" ] + ] + ] ] - -- HTTP type alias LoginResult = @@ -1709,10 +2440,11 @@ fetchUsers token = userDecoder : Decoder User userDecoder = - Decode.map3 User + Decode.map4 User (field "id" int) (field "username" string) (field "is_admin" bool) + (field "weekly_hours" float) -- NEU fetchAllTimeEntries : String -> Cmd Msg fetchAllTimeEntries token = @@ -1752,12 +2484,14 @@ fetchWeeklyHours token = weeklyHoursDecoder : Decoder WeeklyHours weeklyHoursDecoder = - Decode.map5 WeeklyHours + Decode.map7 WeeklyHours (field "user_id" int) (field "username" string) - (field "week" int) (field "year" int) + (field "week" int) (field "total_hours" float) + (field "expected_hours" float) -- NEU + (field "remaining_hours" float) -- NEU fetchWeekDates : String -> Int -> Int -> Cmd Msg fetchWeekDates token year week = @@ -1791,3 +2525,88 @@ 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 = + 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 + [ ("weekly_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 + } +