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
+ }
+