From d74046522b0e1fc48ef217342fec474357247a02 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 5 Nov 2025 07:16:38 +0100 Subject: [PATCH] feat: add Logging Middleware, saving Token and better Handling --- backend/database.go | 82 ++++- backend/handlers.go | 80 ++++- backend/main.go | 2 + backend/middleware.go | 229 +++++++----- backend/models.go | 36 +- backend/static/index.html | 15 +- frontend/src/Main.elm | 707 +++++++++++++++++++++++++++++++------- frontend/src/Ports.elm | 11 + 8 files changed, 926 insertions(+), 236 deletions(-) create mode 100644 frontend/src/Ports.elm diff --git a/backend/database.go b/backend/database.go index 8fe58c0..bfbe726 100644 --- a/backend/database.go +++ b/backend/database.go @@ -45,8 +45,10 @@ func createTables(db *sql.DB) { 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), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (schedule_id) REFERENCES schedules(id) )`, } @@ -58,9 +60,7 @@ func createTables(db *sql.DB) { } // Create default admin user (password: admin123) - // Hash generated with bcrypt hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) - fmt.Println(string(hash)) _, err := db.Exec(` INSERT OR IGNORE INTO users (id, username, password, is_admin) VALUES (?, ?, ?, ?)`, @@ -135,11 +135,17 @@ func DeleteSchedule(db *sql.DB, id int) error { } func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { - _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type) VALUES (?, ?, ?, ?)", - entry.UserID, entry.ScheduleID, entry.Date, entry.Type) + _, 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) return err } +// func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { +// _, err := db.Exec("INSERT INTO time_entries (user_id, schedule_id, date, type) VALUES (?, ?, ?, ?)", +// entry.UserID, entry.ScheduleID, entry.Date, entry.Type) +// return err +// } + func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries WHERE user_id = ? ORDER BY date DESC, created_at DESC", userID) @@ -160,7 +166,12 @@ func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { } func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { - rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries ORDER BY date DESC, created_at DESC") + rows, err := db.Query(` + SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username + FROM time_entries te + JOIN users u ON te.user_id = u.id + ORDER BY te.date DESC, te.created_at DESC + `) if err != nil { return nil, err } @@ -169,10 +180,67 @@ func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { var entries []TimeEntry for rows.Next() { var e TimeEntry - if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.CreatedAt); err != nil { + if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.StartTime, &e.EndTime, &e.CreatedAt, &e.Username); err != nil { continue } entries = append(entries, e) } return entries, nil } + +// func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { +// rows, err := db.Query("SELECT id, user_id, schedule_id, date, type, created_at FROM time_entries ORDER BY date DESC, created_at DESC") +// if err != nil { +// return nil, err +// } +// defer rows.Close() + +// var entries []TimeEntry +// for rows.Next() { +// var e TimeEntry +// if err := rows.Scan(&e.ID, &e.UserID, &e.ScheduleID, &e.Date, &e.Type, &e.CreatedAt); err != nil { +// continue +// } +// entries = append(entries, e) +// } +// return entries, nil +// } +func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { + rows, err := db.Query(` + SELECT + te.user_id, + u.username, + CAST(strftime('%W', te.date) AS INTEGER) as week, + CAST(strftime('%Y', te.date) AS INTEGER) as year, + 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 + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var hours []WeeklyHours + for rows.Next() { + var h WeeklyHours + if err := rows.Scan(&h.UserID, &h.Username, &h.Week, &h.Year, &h.TotalHours); err != nil { + continue + } + hours = append(hours, h) + } + return hours, nil +} + +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 +} diff --git a/backend/handlers.go b/backend/handlers.go index ae59045..04b9a73 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "encoding/json" + "log" "net/http" "strconv" @@ -126,25 +127,25 @@ func (app *App) GetUsersHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(users) } -func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { - userIDStr := r.Header.Get("X-User-ID") - userID, _ := strconv.Atoi(userIDStr) +// func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { +// userIDStr := r.Header.Get("X-User-ID") +// userID, _ := strconv.Atoi(userIDStr) - var entry TimeEntry - if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } +// var entry TimeEntry +// if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { +// http.Error(w, err.Error(), http.StatusBadRequest) +// return +// } - entry.UserID = userID +// entry.UserID = userID - if err := CreateTimeEntry(app.DB, &entry); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } +// if err := CreateTimeEntry(app.DB, &entry); err != nil { +// http.Error(w, err.Error(), http.StatusInternalServerError) +// return +// } - w.WriteHeader(http.StatusCreated) -} +// w.WriteHeader(http.StatusCreated) +// } func (app *App) GetMyTimeEntriesHandler(w http.ResponseWriter, r *http.Request) { userIDStr := r.Header.Get("X-User-ID") @@ -170,3 +171,52 @@ func (app *App) GetAllTimeEntriesHandler(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) } + +func (app *App) CreateTimeEntryHandler(w http.ResponseWriter, r *http.Request) { + userIDStr := r.Header.Get("X-User-ID") + userID, _ := strconv.Atoi(userIDStr) + + var entry TimeEntry + if err := json.NewDecoder(r.Body).Decode(&entry); err != nil { + log.Print("Error on Decoding occured") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + entry.UserID = userID + + if err := CreateTimeEntry(app.DB, &entry); err != nil { + log.Print("Error on creating time entry in Database occured") + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (app *App) DeleteUserHandler(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + if err := DeleteUser(app.DB, id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (app *App) GetWeeklyHoursHandler(w http.ResponseWriter, r *http.Request) { + hours, err := GetWeeklyHours(app.DB) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hours) +} diff --git a/backend/main.go b/backend/main.go index 5ceb1bc..6b73d66 100644 --- a/backend/main.go +++ b/backend/main.go @@ -30,7 +30,9 @@ func main() { http.HandleFunc("/api/admin/schedules/delete", CORS(AdminMiddleware(app.DeleteScheduleHandler))) http.HandleFunc("/api/admin/users", CORS(AdminMiddleware(app.CreateUserHandler))) http.HandleFunc("/api/admin/users/list", CORS(AdminMiddleware(app.GetUsersHandler))) + http.HandleFunc("/api/admin/users/delete", CORS(AdminMiddleware(app.DeleteUserHandler))) // Neu http.HandleFunc("/api/admin/time-entries", CORS(AdminMiddleware(app.GetAllTimeEntriesHandler))) + http.HandleFunc("/api/admin/weekly-hours", CORS(AdminMiddleware(app.GetWeeklyHoursHandler))) // Neu // Serve frontend fs := http.FileServer(http.Dir("./static")) diff --git a/backend/middleware.go b/backend/middleware.go index 82d96bf..569adc3 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,125 +1,180 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "strings" - "time" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" ) var jwtSecret = []byte("your-secret-key-change-in-production") type Claims struct { - UserID int `json:"user_id"` - Username string `json:"username"` - IsAdmin bool `json:"is_admin"` - Exp int64 `json:"exp"` + UserID int `json:"user_id"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + Exp int64 `json:"exp"` +} + +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool + written int64 +} + +func wrapResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ + ResponseWriter: w, + status: http.StatusOK, + } +} + +func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + rw.status = code + rw.ResponseWriter.WriteHeader(code) + rw.wroteHeader = true +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.wroteHeader { + rw.WriteHeader(http.StatusOK) + } + n, err := rw.ResponseWriter.Write(b) + rw.written += int64(n) + return n, err } func createToken(userID int, username string, isAdmin bool) (string, error) { - claims := Claims{ - UserID: userID, - Username: username, - IsAdmin: isAdmin, - Exp: time.Now().Add(24 * time.Hour).Unix(), - } + claims := Claims{ + UserID: userID, + Username: username, + IsAdmin: isAdmin, + Exp: time.Now().Add(24 * time.Hour).Unix(), + } - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - - payload, _ := json.Marshal(claims) - payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - message := header + "." + payloadEncoded - - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + payload, _ := json.Marshal(claims) + payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) - return message + "." + signature, nil + message := header + "." + payloadEncoded + + h := hmac.New(sha256.New, jwtSecret) + h.Write([]byte(message)) + signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return message + "." + signature, nil } func verifyToken(tokenString string) (*Claims, error) { - parts := strings.Split(tokenString, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid token format") - } + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format") + } - message := parts[0] + "." + parts[1] - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + message := parts[0] + "." + parts[1] + h := hmac.New(sha256.New, jwtSecret) + h.Write([]byte(message)) + expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - if parts[2] != expectedSignature { - return nil, fmt.Errorf("invalid signature") - } + if parts[2] != expectedSignature { + return nil, fmt.Errorf("invalid signature") + } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, err + } - var claims Claims - if err := json.Unmarshal(payload, &claims); err != nil { - return nil, err - } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } - if time.Now().Unix() > claims.Exp { - return nil, fmt.Errorf("token expired") - } + if time.Now().Unix() > claims.Exp { + return nil, fmt.Errorf("token expired") + } - return &claims, nil + return &claims, nil } func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + return func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - claims, err := verifyToken(tokenString) - if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + claims, err := verifyToken(tokenString) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } - r.Header.Set("X-User-ID", fmt.Sprintf("%d", claims.UserID)) - r.Header.Set("X-Username", claims.Username) - r.Header.Set("X-Is-Admin", fmt.Sprintf("%t", claims.IsAdmin)) + r.Header.Set("X-User-ID", fmt.Sprintf("%d", claims.UserID)) + r.Header.Set("X-Username", claims.Username) + r.Header.Set("X-Is-Admin", fmt.Sprintf("%t", claims.IsAdmin)) - next(w, r) - } + next(w, r) + } } func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { - return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { - isAdmin := r.Header.Get("X-Is-Admin") == "true" - if !isAdmin { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - next(w, r) - }) + return AuthMiddleware(func(w http.ResponseWriter, r *http.Request) { + isAdmin := r.Header.Get("X-Is-Admin") == "true" + if !isAdmin { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + next(w, r) + }) +} + +func LoggingMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := wrapResponseWriter(w) + + defer func() { + slog.Info("http request", + "method", r.Method, + "path", r.URL.Path, + "query", r.URL.RawQuery, + "status", wrapped.status, + "duration_ms", time.Since(start).Milliseconds(), + "client_ip", r.RemoteAddr, + "user_agent", r.UserAgent(), + "bytes_written", wrapped.written, + ) + }() + + next(wrapped, r) + } } func CORS(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + return LoggingMiddleware(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } - next(w, r) - } + next(w, r) + }) } - diff --git a/backend/models.go b/backend/models.go index 2ef8dd5..fa69693 100644 --- a/backend/models.go +++ b/backend/models.go @@ -4,6 +4,26 @@ import ( "time" ) +type TimeEntry struct { + ID int `json:"id"` + UserID int `json:"user_id"` + ScheduleID int `json:"schedule_id"` + Date string `json:"date"` + Type string `json:"type"` + StartTime string `json:"start_time"` // Neu + EndTime string `json:"end_time"` // Neu + CreatedAt time.Time `json:"created_at"` + Username string `json:"username"` // Neu - für Join +} + +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"` +} + type User struct { ID int `json:"id"` Username string `json:"username"` @@ -20,14 +40,14 @@ type Schedule struct { Title string `json:"title"` } -type TimeEntry struct { - ID int `json:"id"` - UserID int `json:"user_id"` - ScheduleID int `json:"schedule_id"` - Date string `json:"date"` - Type string `json:"type"` // "lesson" or "break" - CreatedAt time.Time `json:"created_at"` -} +// type TimeEntry struct { +// ID int `json:"id"` +// UserID int `json:"user_id"` +// ScheduleID int `json:"schedule_id"` +// Date string `json:"date"` +// Type string `json:"type"` // "lesson" or "break" +// CreatedAt time.Time `json:"created_at"` +// } type LoginRequest struct { Username string `json:"username"` diff --git a/backend/static/index.html b/backend/static/index.html index 825d8bc..426d625 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -15,8 +15,21 @@
diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 61dc176..c68e7e6 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,18 +1,25 @@ -module Main exposing (..) +port module Main exposing (..) import Browser import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Http -import Json.Decode as Decode exposing (Decoder, field, int, string, bool, list) +import Json.Decode as Decode exposing (Decoder, field, int, string, bool, list, float) import Json.Encode as Encode +import Task import Time +-- PORTS + +port saveToken : String -> Cmd msg +port removeToken : () -> Cmd msg + + -- MAIN -main : Program () Model Msg +main : Program (Maybe String) Model Msg main = Browser.element { init = init @@ -26,6 +33,7 @@ main = type alias Model = { page : Page + , activeTab : AdminTab , username : String , password : String , token : Maybe String @@ -33,8 +41,12 @@ type alias Model = , schedules : List Schedule , users : List User , timeEntries : List TimeEntry - , selectedEntries : List Int - , currentDate : String + , weeklyHours : List WeeklyHours + , selectedEntries : List SelectedEntry + , currentWeek : Int + , currentYear : Int + , currentTime : Time.Posix + , zone : Time.Zone , newSchedule : NewSchedule , newUser : NewUser , error : Maybe String @@ -45,6 +57,11 @@ type Page | UserDashboard | AdminDashboard +type AdminTab + = ScheduleTab + | UsersTab + | TimeEntriesTab + type alias Schedule = { id : Int , dayOfWeek : Int @@ -66,6 +83,22 @@ type alias TimeEntry = , scheduleId : Int , date : String , entryType : String + , username : String + , startTime : String + , endTime : String + } + +type alias WeeklyHours = + { userId : Int + , username : String + , week : Int + , year : Int + , totalHours : Float + } + +type alias SelectedEntry = + { scheduleId : Int + , dayOfWeek : Int } type alias NewSchedule = @@ -82,24 +115,41 @@ type alias NewUser = , isAdmin : Bool } -init : () -> (Model, Cmd Msg) -init _ = - ( { page = LoginPage - , username = "" - , password = "" - , token = Nothing - , isAdmin = False - , schedules = [] - , users = [] - , timeEntries = [] - , selectedEntries = [] - , currentDate = "" - , newSchedule = NewSchedule "" "" "" "lesson" "" - , newUser = NewUser "" "" False - , error = Nothing - } - , Cmd.none - ) +init : Maybe String -> (Model, Cmd Msg) +init storedToken = + let + model = + { page = if storedToken /= Nothing then UserDashboard else LoginPage + , activeTab = ScheduleTab + , username = "" + , password = "" + , token = storedToken + , isAdmin = False + , schedules = [] + , users = [] + , timeEntries = [] + , weeklyHours = [] + , selectedEntries = [] + , currentWeek = 1 + , currentYear = 2025 + , currentTime = Time.millisToPosix 0 + , zone = Time.utc + , newSchedule = NewSchedule "" "" "" "lesson" "" + , newUser = NewUser "" "" False + , error = Nothing + } + + cmd = + case storedToken of + Just token -> + Cmd.batch + [ Task.perform SetTime Time.now + , fetchSchedules (Just token) + ] + Nothing -> + Task.perform SetTime Time.now + in + (model, cmd) -- UPDATE @@ -110,11 +160,15 @@ type Msg | Login | LoginResponse (Result Http.Error LoginResult) | Logout + | SetTime Time.Posix | FetchSchedules | SchedulesReceived (Result Http.Error (List Schedule)) - | ToggleScheduleSelection Int + | ToggleScheduleSelection Int Int | SaveTimeEntries | TimeEntriesSaved (Result Http.Error ()) + | PreviousWeek + | NextWeek + | SwitchTab AdminTab | UpdateNewScheduleDay String | UpdateNewScheduleStart String | UpdateNewScheduleEnd String @@ -129,11 +183,14 @@ type Msg | UpdateNewUserAdmin Bool | CreateUser | UserCreated (Result Http.Error ()) + | DeleteUser Int + | UserDeleted (Result Http.Error ()) | FetchUsers | UsersReceived (Result Http.Error (List User)) | FetchAllTimeEntries | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | UpdateCurrentDate String + | FetchWeeklyHours + | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) update : Msg -> Model -> (Model, Cmd Msg) update msg model = @@ -156,13 +213,32 @@ update msg model = , isAdmin = result.isAdmin , page = newPage , error = Nothing - }, fetchSchedules (Just result.token)) + }, Cmd.batch + [ saveToken result.token + , fetchSchedules (Just result.token) + ]) LoginResponse (Err _) -> ({ model | error = Just "Login fehlgeschlagen" }, Cmd.none) Logout -> - init () + ({ model + | page = LoginPage + , token = Nothing + , isAdmin = False + , username = "" + , password = "" + }, removeToken ()) + + SetTime time -> + let + (year, week) = getISOWeekFromPosix time + in + ({ model + | currentTime = time + , currentWeek = week + , currentYear = year + }, Cmd.none) FetchSchedules -> (model, fetchSchedules model.token) @@ -173,20 +249,21 @@ update msg model = SchedulesReceived (Err _) -> ({ model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none) - ToggleScheduleSelection scheduleId -> + ToggleScheduleSelection scheduleId dayOfWeek -> let + entry = { scheduleId = scheduleId, dayOfWeek = dayOfWeek } newSelected = - if List.member scheduleId model.selectedEntries then - List.filter (\id -> id /= scheduleId) model.selectedEntries + if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then + List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries else - scheduleId :: model.selectedEntries + entry :: model.selectedEntries in ({ model | selectedEntries = newSelected }, Cmd.none) SaveTimeEntries -> case model.token of Just token -> - (model, saveTimeEntries token model.selectedEntries model.currentDate) + ({ model | error = Nothing }, saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules) Nothing -> (model, Cmd.none) @@ -196,6 +273,42 @@ update msg model = TimeEntriesSaved (Err _) -> ({ model | error = Just "Fehler beim Speichern" }, Cmd.none) + PreviousWeek -> + let + (newYear, newWeek) = previousWeek model.currentYear model.currentWeek + in + ({ model | currentWeek = newWeek, currentYear = newYear, selectedEntries = [] }, Cmd.none) + + NextWeek -> + let + (newYear, newWeek) = nextWeek model.currentYear model.currentWeek + in + ({ model | currentWeek = newWeek, currentYear = newYear, selectedEntries = [] }, Cmd.none) + + SwitchTab tab -> + let + cmd = case tab of + UsersTab -> + case model.token of + Just token -> + fetchUsers token + Nothing -> + Cmd.none + -- fetchUsers model.token + TimeEntriesTab -> + case model.token of + Just token -> + Cmd.batch + [ fetchAllTimeEntries token + , fetchWeeklyHours token + ] + Nothing -> + Cmd.none + _ -> + Cmd.none + in + ({ model | activeTab = tab }, cmd) + UpdateNewScheduleDay day -> let oldSchedule = model.newSchedule @@ -296,12 +409,30 @@ update msg model = Just token -> ({ model | newUser = emptyUser }, fetchUsers token) Nothing -> - ({ model | error = Just "Kein Token vorhanden" }, Cmd.none) + (model, Cmd.none) -- ({ model | newUser = emptyUser }, fetchUsers model.token) UserCreated (Err _) -> ({ model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none) + DeleteUser userId -> + case model.token of + Just token -> + (model, deleteUser token userId) + Nothing -> + (model, Cmd.none) + + UserDeleted (Ok _) -> + case model.token of + Just token -> + (model, fetchUsers token) + Nothing -> + (model, Cmd.none) + -- (model, fetchUsers model.token) + + UserDeleted (Err _) -> + ({ model | error = Just "Fehler beim Löschen des Benutzers" }, Cmd.none) + FetchUsers -> case model.token of Just token -> @@ -328,8 +459,18 @@ update msg model = AllTimeEntriesReceived (Err _) -> ({ model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none) - UpdateCurrentDate date -> - ({ model | currentDate = date }, Cmd.none) + FetchWeeklyHours -> + case model.token of + Just token -> + (model, fetchWeeklyHours token) + Nothing -> + (model, Cmd.none) + + WeeklyHoursReceived (Ok hours) -> + ({ model | weeklyHours = hours }, Cmd.none) + + WeeklyHoursReceived (Err _) -> + ({ model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none) -- SUBSCRIPTIONS @@ -339,6 +480,161 @@ subscriptions model = Sub.none +-- HELPER FUNCTIONS + +getISOWeekFromPosix : Time.Posix -> (Int, Int) +getISOWeekFromPosix time = + let + year = Time.toYear Time.utc time + month = Time.toMonth Time.utc time |> monthToInt + day = Time.toDay Time.utc time + in + (year, getISOWeek year month day) + +monthToInt : Time.Month -> Int +monthToInt month = + case month of + Time.Jan -> 1 + Time.Feb -> 2 + Time.Mar -> 3 + Time.Apr -> 4 + Time.May -> 5 + Time.Jun -> 6 + Time.Jul -> 7 + Time.Aug -> 8 + Time.Sep -> 9 + Time.Oct -> 10 + Time.Nov -> 11 + Time.Dec -> 12 + +getISOWeek : Int -> Int -> Int -> Int +getISOWeek year month day = + let + dayOfYear = getDayOfYear year month day + jan1DayOfWeek = getDayOfWeek year 1 1 + weekDay = modBy 7 (jan1DayOfWeek + dayOfYear - 1) + weekNumber = ((dayOfYear + jan1DayOfWeek - 1) // 7) + 1 + in + if weekNumber > 52 then 52 else if weekNumber < 1 then 1 else weekNumber + +getDayOfYear : Int -> Int -> Int -> Int +getDayOfYear year month day = + let + daysInMonth = [31, if isLeapYear year then 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + daysBefore = List.take (month - 1) daysInMonth |> List.sum + in + daysBefore + day + +isLeapYear : Int -> Bool +isLeapYear year = + (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) + +getDayOfWeek : Int -> Int -> Int -> Int +getDayOfWeek year month day = + let + adjustedMonth = if month < 3 then month + 12 else month + adjustedYear = if month < 3 then year - 1 else year + q = day + m = adjustedMonth + k = modBy 100 adjustedYear + j = adjustedYear // 100 + h = (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 + in + (h + 5) |> modBy 7 + +getDateForWeekDay : Int -> Int -> Int -> String +getDateForWeekDay year week dayOfWeek = + let + jan4DayOfWeek = getDayOfWeek year 1 4 + daysToMonday = jan4DayOfWeek + firstMondayOfYear = 4 - daysToMonday + daysFromFirstMonday = (week - 1) * 7 + dayOfWeek + totalDays = firstMondayOfYear + daysFromFirstMonday + + (finalYear, finalMonth, finalDay) = addDaysToDate year 1 1 totalDays + in + String.fromInt finalYear ++ "-" ++ + String.padLeft 2 '0' (String.fromInt finalMonth) ++ "-" ++ + String.padLeft 2 '0' (String.fromInt finalDay) + +addDaysToDate : Int -> Int -> Int -> Int -> (Int, Int, Int) +addDaysToDate year month day daysToAdd = + let + daysInMonth m y = + case m of + 1 -> 31 + 2 -> if isLeapYear y then 29 else 28 + 3 -> 31 + 4 -> 30 + 5 -> 31 + 6 -> 30 + 7 -> 31 + 8 -> 31 + 9 -> 30 + 10 -> 31 + 11 -> 30 + 12 -> 31 + _ -> 0 + + helper y m d remaining = + if remaining <= 0 then + (y, m, d) + else + let + daysInCurrentMonth = daysInMonth m y + daysLeftInMonth = daysInCurrentMonth - d + 1 + in + if remaining < daysLeftInMonth then + (y, m, d + remaining) + else if m == 12 then + helper (y + 1) 1 1 (remaining - daysLeftInMonth) + else + helper y (m + 1) 1 (remaining - daysLeftInMonth) + in + helper year month day daysToAdd + +previousWeek : Int -> Int -> (Int, Int) +previousWeek year week = + if week == 1 then + (year - 1, 52) + else + (year, week - 1) + +nextWeek : Int -> Int -> (Int, Int) +nextWeek year week = + if week == 52 then + (year + 1, 1) + else + (year, week + 1) + +getWeekDateRange : Int -> Int -> String +getWeekDateRange year week = + let + mondayDate = getDateForWeekDay year week 0 + fridayDate = getDateForWeekDay year week 4 + in + mondayDate ++ " bis " ++ fridayDate + +calculateHours : String -> String -> Float +calculateHours startTime endTime = + let + parseTime timeStr = + case String.split ":" timeStr of + [h, m] -> + (String.toFloat h |> Maybe.withDefault 0) + + ((String.toFloat m |> Maybe.withDefault 0) / 60) + _ -> + 0 + + start = parseTime startTime + end = parseTime endTime + in + if end > start then + end - start + else + 0 + + -- VIEW view : Model -> Html Msg @@ -418,38 +714,29 @@ viewUserDashboard model = , div [ class "navbar-menu" ] [ div [ class "navbar-end" ] [ div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] + [ span [ class "has-text-white mr-4" ] [ text model.username ] + , button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] ] ] ] ] , section [ class "section" ] [ div [ class "container" ] - [ h2 [ class "title" ] [ text "Stundenplan" ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.currentDate - , onInput UpdateCurrentDate - ] [] - ] - ] - , viewScheduleGrid model - , div [ class "field" ] + [ viewWeekNavigation model + , h2 [ class "title" ] [ text "Stundenplan" ] + , viewScheduleGridWithWeek model + , div [ class "field mt-4" ] [ div [ class "control" ] [ button [ class "button is-primary is-large is-fullwidth" , onClick SaveTimeEntries - , disabled (List.isEmpty model.selectedEntries || String.isEmpty model.currentDate) + , disabled (List.isEmpty model.selectedEntries) ] [ text "Speichern" ] ] ] , case model.error of Just err -> - div [ class "notification is-danger" ] [ text err ] + div [ class "notification is-danger mt-4" ] [ text err ] Nothing -> text "" ] @@ -468,7 +755,8 @@ viewAdminDashboard model = , div [ class "navbar-menu" ] [ div [ class "navbar-end" ] [ div [ class "navbar-item" ] - [ button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] + [ span [ class "has-text-white mr-4" ] [ text model.username ] + , button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] ] ] ] @@ -477,70 +765,136 @@ viewAdminDashboard model = [ div [ class "container" ] [ div [ class "tabs is-boxed" ] [ ul [] - [ li [ class "is-active" ] [ a [] [ text "Stundenplan" ] ] - , li [] [ a [ onClick FetchUsers ] [ text "Benutzer" ] ] - , li [] [ a [ onClick FetchAllTimeEntries ] [ text "Zeiteinträge" ] ] + [ li [ classList [("is-active", model.activeTab == ScheduleTab)] ] + [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] + , li [ classList [("is-active", model.activeTab == UsersTab)] ] + [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] + , li [ classList [("is-active", model.activeTab == TimeEntriesTab)] ] + [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] ] ] - , h2 [ class "title" ] [ text "Stundenplan verwalten" ] - , viewScheduleForm model - , viewScheduleList model - , h2 [ class "title" ] [ text "Benutzer anlegen" ] - , viewUserForm model - , if not (List.isEmpty model.users) then - viewUserList model - else - text "" - , if not (List.isEmpty model.timeEntries) then - viewTimeEntriesList model - else - text "" + , case model.activeTab of + ScheduleTab -> + viewScheduleTab model + UsersTab -> + viewUsersTab model + TimeEntriesTab -> + viewTimeEntriesTab model ] ] ] -viewScheduleGrid : Model -> Html Msg -viewScheduleGrid model = +viewScheduleTab : Model -> Html Msg +viewScheduleTab model = + div [] + [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] + , viewScheduleForm model + , viewScheduleList model + ] + +viewUsersTab : Model -> Html Msg +viewUsersTab model = + div [] + [ h2 [ class "title" ] [ text "Benutzer verwalten" ] + , viewUserForm model + , viewUserList model + ] + +viewTimeEntriesTab : Model -> Html Msg +viewTimeEntriesTab model = + div [] + [ viewWeekNavigation model + , h2 [ class "title" ] [ text "Wochenstunden Übersicht" ] + , viewWeeklyHoursSummary model + , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] + , viewTimeEntriesList model + ] + +viewWeekNavigation : Model -> Html Msg +viewWeekNavigation model = + div [ class "box" ] + [ nav [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick PreviousWeek + ] + [ text "← Vorherige Woche" ] + ] + ] + , div [ class "level-item has-text-centered" ] + [ div [] + [ p [ class "heading" ] [ text "Kalenderwoche" ] + , p [ class "title" ] + [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] + , p [ class "subtitle is-6" ] + [ text (getWeekDateRange model.currentYear model.currentWeek) ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick NextWeek + ] + [ text "Nächste Woche →" ] + ] + ] + ] + ] + +viewScheduleGridWithWeek : Model -> Html Msg +viewScheduleGridWithWeek model = let days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"] groupedSchedules = List.range 0 4 |> List.map (\day -> - List.filter (\s -> s.dayOfWeek == day) model.schedules + ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) ) in div [ class "table-container" ] [ table [ class "table is-bordered is-fullwidth" ] [ thead [] - [ tr [] (List.map (\day -> th [] [ text day ]) days) + [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) ] , tbody [] [ tr [] - (List.map (viewDayColumn model) groupedSchedules) + (List.map (viewDayColumnWithWeek model) groupedSchedules) ] ] ] -viewDayColumn : Model -> List Schedule -> Html Msg -viewDayColumn model schedules = - td [ class "has-background-light" ] - (List.map (viewScheduleItem model) schedules) - -viewScheduleItem : Model -> Schedule -> Html Msg -viewScheduleItem model schedule = +viewDayColumnWithWeek : Model -> (Int, List Schedule) -> Html Msg +viewDayColumnWithWeek model (dayOfWeek, schedules) = let - isSelected = List.member schedule.id model.selectedEntries - boxClass = if isSelected then "box has-background-primary-light" else "box" + dateForDay = getDateForWeekDay model.currentYear model.currentWeek dayOfWeek + in + td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] + [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] + [ text dateForDay ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] + +viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg +viewScheduleItemWithDay model dayOfWeek schedule = + let + isSelected = List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries + boxClass = if isSelected then "box has-background-success-light" else "box has-background-white" typeText = if schedule.scheduleType == "break" then " (Pause)" else "" in div [ class boxClass - , onClick (ToggleScheduleSelection schedule.id) + , onClick (ToggleScheduleSelection schedule.id dayOfWeek) , style "cursor" "pointer" , style "margin-bottom" "0.5rem" + , style "padding" "0.75rem" ] - [ p [ class "has-text-weight-bold" ] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [] [ text (schedule.title ++ typeText) ] + [ p [ class "has-text-weight-bold is-size-7" ] + [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , p [ class "is-size-7" ] + [ text (schedule.title ++ typeText) ] ] viewScheduleForm : Model -> Html Msg @@ -737,6 +1091,7 @@ viewUserList model = [ th [] [ text "ID" ] , th [] [ text "Benutzername" ] , th [] [ text "Rolle" ] + , th [] [ text "Aktion" ] ] ] , tbody [] @@ -750,33 +1105,100 @@ viewUserRow user = [ td [] [ text (String.fromInt user.id) ] , td [] [ text user.username ] , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] + , td [] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + else + button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] [ text "Löschen" ] + ] + ] + +viewWeeklyHoursSummary : Model -> Html Msg +viewWeeklyHoursSummary model = + let + filteredHours = List.filter + (\h -> h.week == model.currentWeek && h.year == model.currentYear) + model.weeklyHours + in + div [ class "box" ] + [ if List.isEmpty filteredHours then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Gesamtstunden" ] + ] + ] + , tbody [] + (List.map viewWeeklyHoursRow filteredHours) + , tfoot [] + [ tr [ class "has-background-light" ] + [ th [] [ text "Gesamt" ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] + ] + ] + ] + ] + +viewWeeklyHoursRow : WeeklyHours -> Html Msg +viewWeeklyHoursRow hours = + tr [] + [ td [] [ text hours.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] ] viewTimeEntriesList : Model -> Html Msg viewTimeEntriesList model = + let + filteredEntries = List.filter + (\e -> + let + parts = String.split "-" e.date + entryYear = parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 0 + entryMonth = parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + entryDay = parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + entryWeek = getISOWeek entryYear entryMonth entryDay + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + in div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Alle Zeiteinträge" ] - , table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Benutzer ID" ] - , th [] [ text "Stundenplan ID" ] - , th [] [ text "Datum" ] - , th [] [ text "Typ" ] + [ if List.isEmpty filteredEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + ] ] + , tbody [] + (List.map viewTimeEntryRow filteredEntries) ] - , tbody [] - (List.map viewTimeEntryRow model.timeEntries) - ] ] viewTimeEntryRow : TimeEntry -> Html Msg viewTimeEntryRow entry = + let + hours = calculateHours entry.startTime entry.endTime + in tr [] - [ td [] [ text (String.fromInt entry.userId) ] - , td [] [ text (String.fromInt entry.scheduleId) ] + [ 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.") ] ] @@ -833,25 +1255,38 @@ scheduleDecoder = (field "type" string) (field "title" string) -saveTimeEntries : String -> List Int -> String -> Cmd Msg -saveTimeEntries token scheduleIds date = +saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Cmd Msg +saveTimeEntriesForWeek token selectedEntries year week schedules = let - requests = List.map (\scheduleId -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/time-entries" - , body = Http.jsonBody <| - Encode.object - [ ("schedule_id", Encode.int scheduleId) - , ("date", Encode.string date) - , ("type", Encode.string "lesson") - ] - , expect = Http.expectWhatever TimeEntriesSaved - , timeout = Nothing - , tracker = Nothing - } - ) scheduleIds + getScheduleById id = + List.filter (\s -> s.id == id) schedules |> List.head + + createRequest entry = + case getScheduleById entry.scheduleId of + Just schedule -> + let + dateStr = getDateForWeekDay year week entry.dayOfWeek + in + Just <| Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/time-entries" + , body = Http.jsonBody <| + Encode.object + [ ("schedule_id", Encode.int entry.scheduleId) + , ("date", Encode.string dateStr) + , ("type", Encode.string schedule.scheduleType) + , ("start_time", Encode.string schedule.startTime) + , ("end_time", Encode.string schedule.endTime) + ] + , expect = Http.expectWhatever TimeEntriesSaved + , timeout = Nothing + , tracker = Nothing + } + Nothing -> + Nothing + + requests = List.filterMap createRequest selectedEntries in case List.head requests of Just cmd -> cmd @@ -909,6 +1344,18 @@ createUser token user = , tracker = Nothing } +deleteUser : String -> Int -> Cmd Msg +deleteUser token userId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/delete?id=" ++ String.fromInt userId + , body = Http.emptyBody + , expect = Http.expectWhatever UserDeleted + , timeout = Nothing + , tracker = Nothing + } + fetchUsers : String -> Cmd Msg fetchUsers token = Http.request @@ -942,9 +1389,33 @@ fetchAllTimeEntries token = timeEntryDecoder : Decoder TimeEntry timeEntryDecoder = - Decode.map5 TimeEntry + Decode.map8 TimeEntry (field "id" int) (field "user_id" int) (field "schedule_id" int) (field "date" string) (field "type" string) + (field "username" string) + (field "start_time" string) + (field "end_time" string) + +fetchWeeklyHours : String -> Cmd Msg +fetchWeeklyHours token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/weekly-hours" + , body = Http.emptyBody + , expect = Http.expectJson WeeklyHoursReceived (Decode.list weeklyHoursDecoder) + , timeout = Nothing + , tracker = Nothing + } + +weeklyHoursDecoder : Decoder WeeklyHours +weeklyHoursDecoder = + Decode.map5 WeeklyHours + (field "user_id" int) + (field "username" string) + (field "week" int) + (field "year" int) + (field "total_hours" float) diff --git a/frontend/src/Ports.elm b/frontend/src/Ports.elm new file mode 100644 index 0000000..4ede617 --- /dev/null +++ b/frontend/src/Ports.elm @@ -0,0 +1,11 @@ +port module Ports exposing (..) + +import Json.Encode as Encode + +-- Outgoing Ports +port saveToken : String -> Cmd msg +port removeToken : () -> Cmd msg + +-- Incoming Ports +port loadToken : (Maybe String -> msg) -> Sub msg +