feat: add Logging Middleware, saving Token and better Handling

This commit is contained in:
Patryk Hegenberg 2025-11-05 07:16:38 +01:00
parent 2c4fc7869a
commit d74046522b
8 changed files with 926 additions and 236 deletions

View file

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

View file

@ -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)
}

View file

@ -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"))

View file

@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
@ -20,6 +21,38 @@ type Claims struct {
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,
@ -108,8 +141,31 @@ func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc {
})
}
func CORS(next http.HandlerFunc) http.HandlerFunc {
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 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")
@ -120,6 +176,5 @@ func CORS(next http.HandlerFunc) http.HandlerFunc {
}
next(w, r)
})
}
}

View file

@ -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"`

View file

@ -15,8 +15,21 @@
<div id="app"></div>
<script src="/elm.js"></script>
<script>
var storedToken = localStorage.getItem('authToken');
var app = Elm.Main.init({
node: document.getElementById('app')
node: document.getElementById('app'),
flags: storedToken
});
// Save token to localStorage
app.ports.saveToken.subscribe(function(token) {
localStorage.setItem('authToken', token);
});
// Remove token from localStorage
app.ports.removeToken.subscribe(function() {
localStorage.removeItem('authToken');
});
</script>
</body>

View file

@ -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
init : Maybe String -> (Model, Cmd Msg)
init storedToken =
let
model =
{ page = if storedToken /= Nothing then UserDashboard else LoginPage
, activeTab = ScheduleTab
, username = ""
, password = ""
, token = Nothing
, token = storedToken
, isAdmin = False
, schedules = []
, users = []
, timeEntries = []
, weeklyHours = []
, selectedEntries = []
, currentDate = ""
, currentWeek = 1
, currentYear = 2025
, currentTime = Time.millisToPosix 0
, zone = Time.utc
, newSchedule = NewSchedule "" "" "" "lesson" ""
, newUser = NewUser "" "" False
, error = Nothing
}
, Cmd.none
)
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" ]
[ 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 "Benutzer ID" ]
, th [] [ text "Stundenplan ID" ]
[ th [] [ text "Mitarbeiter" ]
, th [] [ text "Datum" ]
, th [] [ text "Zeit" ]
, th [] [ text "Typ" ]
, th [ class "has-text-right" ] [ text "Stunden" ]
]
]
, tbody []
(List.map viewTimeEntryRow model.timeEntries)
(List.map viewTimeEntryRow filteredEntries)
]
]
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
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 scheduleId)
, ("date", Encode.string date)
, ("type", Encode.string "lesson")
[ ("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
}
) scheduleIds
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)

11
frontend/src/Ports.elm Normal file
View file

@ -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