feat: initial project commit

This commit is contained in:
Patryk Hegenberg 2025-11-04 22:20:09 +01:00
commit 2c4fc7869a
10 changed files with 1632 additions and 0 deletions

178
backend/database.go Normal file
View file

@ -0,0 +1,178 @@
package main
import (
"database/sql"
"fmt"
"log"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
func InitDB(filepath string) *sql.DB {
db, err := sql.Open("sqlite", filepath)
if err != nil {
log.Fatal(err)
}
if err = db.Ping(); err != nil {
log.Fatal(err)
}
createTables(db)
return db
}
func createTables(db *sql.DB) {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_of_week INTEGER NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
schedule_id INTEGER NOT NULL,
date TEXT NOT NULL,
type TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (schedule_id) REFERENCES schedules(id)
)`,
}
for _, query := range queries {
if _, err := db.Exec(query); err != nil {
log.Fatal(err)
}
}
// 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 (?, ?, ?, ?)`,
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)
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 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)
return err
}
func GetAllSchedules(db *sql.DB) ([]Schedule, error) {
rows, err := db.Query("SELECT id, day_of_week, start_time, end_time, type, title FROM schedules ORDER BY day_of_week, start_time")
if err != nil {
return nil, err
}
defer rows.Close()
var schedules []Schedule
for rows.Next() {
var s Schedule
if err := rows.Scan(&s.ID, &s.DayOfWeek, &s.StartTime, &s.EndTime, &s.Type, &s.Title); err != nil {
continue
}
schedules = append(schedules, s)
}
return schedules, nil
}
func DeleteSchedule(db *sql.DB, id int) error {
_, err := db.Exec("DELETE FROM schedules WHERE id = ?", id)
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)
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 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
}

21
backend/go.mod Normal file
View file

@ -0,0 +1,21 @@
module school-timetracker
go 1.25.3
require (
golang.org/x/crypto v0.43.0
modernc.org/sqlite v1.40.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

51
backend/go.sum Normal file
View file

@ -0,0 +1,51 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

172
backend/handlers.go Normal file
View file

@ -0,0 +1,172 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"golang.org/x/crypto/bcrypt"
)
type App struct {
DB *sql.DB
}
func (app *App) LoginHandler(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := GetUserByUsername(app.DB, req.Username)
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := createToken(user.ID, user.Username, user.IsAdmin)
if err != nil {
http.Error(w, "Error creating token", http.StatusInternalServerError)
return
}
response := LoginResponse{
Token: token,
Username: user.Username,
IsAdmin: user.IsAdmin,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (app *App) GetSchedulesHandler(w http.ResponseWriter, r *http.Request) {
schedules, err := GetAllSchedules(app.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(schedules)
}
func (app *App) CreateScheduleHandler(w http.ResponseWriter, r *http.Request) {
var schedule Schedule
if err := json.NewDecoder(r.Body).Decode(&schedule); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := CreateSchedule(app.DB, &schedule); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func (app *App) DeleteScheduleHandler(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 := DeleteSchedule(app.DB, id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (app *App) CreateUserHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
IsAdmin bool `json:"is_admin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Error hashing password", http.StatusInternalServerError)
return
}
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func (app *App) GetUsersHandler(w http.ResponseWriter, r *http.Request) {
users, err := GetAllUsers(app.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
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)
var entry TimeEntry
if err := json.NewDecoder(r.Body).Decode(&entry); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
entry.UserID = userID
if err := CreateTimeEntry(app.DB, &entry); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func (app *App) GetMyTimeEntriesHandler(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Header.Get("X-User-ID")
userID, _ := strconv.Atoi(userIDStr)
entries, err := GetTimeEntriesByUser(app.DB, userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
func (app *App) GetAllTimeEntriesHandler(w http.ResponseWriter, r *http.Request) {
entries, err := GetAllTimeEntries(app.DB)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}

46
backend/main.go Normal file
View file

@ -0,0 +1,46 @@
package main
import (
"log"
"net/http"
"os"
)
func main() {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./timetracking.db"
}
db := InitDB(dbPath)
defer db.Close()
app := &App{DB: db}
// Public routes
http.HandleFunc("/api/login", CORS(app.LoginHandler))
// Protected routes
http.HandleFunc("/api/schedules", CORS(AuthMiddleware(app.GetSchedulesHandler)))
http.HandleFunc("/api/time-entries", CORS(AuthMiddleware(app.CreateTimeEntryHandler)))
http.HandleFunc("/api/my-time-entries", CORS(AuthMiddleware(app.GetMyTimeEntriesHandler)))
// Admin routes
http.HandleFunc("/api/admin/schedules", CORS(AdminMiddleware(app.CreateScheduleHandler)))
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/time-entries", CORS(AdminMiddleware(app.GetAllTimeEntriesHandler)))
// Serve frontend
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}

125
backend/middleware.go Normal file
View file

@ -0,0 +1,125 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"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"`
}
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(),
}
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
payload, _ := json.Marshal(claims)
payloadEncoded := base64.RawURLEncoding.EncodeToString(payload)
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")
}
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")
}
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
}
if time.Now().Unix() > claims.Exp {
return nil, fmt.Errorf("token expired")
}
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
}
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))
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)
})
}
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")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}

41
backend/models.go Normal file
View file

@ -0,0 +1,41 @@
package main
import (
"time"
)
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"-"`
IsAdmin bool `json:"is_admin"`
}
type Schedule struct {
ID int `json:"id"`
DayOfWeek int `json:"day_of_week"` // 0=Monday, 4=Friday
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Type string `json:"type"` // "lesson" or "break"
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 LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}

23
backend/static/index.html Normal file
View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Schulzeit Erfassung</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<style>
html, body {
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script src="/elm.js"></script>
<script>
var app = Elm.Main.init({
node: document.getElementById('app')
});
</script>
</body>
</html>

25
frontend/elm.json Normal file
View file

@ -0,0 +1,25 @@
{
"type": "application",
"source-directories": ["src"],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/time": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

950
frontend/src/Main.elm Normal file
View file

@ -0,0 +1,950 @@
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.Encode as Encode
import Time
-- MAIN
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
-- MODEL
type alias Model =
{ page : Page
, username : String
, password : String
, token : Maybe String
, isAdmin : Bool
, schedules : List Schedule
, users : List User
, timeEntries : List TimeEntry
, selectedEntries : List Int
, currentDate : String
, newSchedule : NewSchedule
, newUser : NewUser
, error : Maybe String
}
type Page
= LoginPage
| UserDashboard
| AdminDashboard
type alias Schedule =
{ id : Int
, dayOfWeek : Int
, startTime : String
, endTime : String
, scheduleType : String
, title : String
}
type alias User =
{ id : Int
, username : String
, isAdmin : Bool
}
type alias TimeEntry =
{ id : Int
, userId : Int
, scheduleId : Int
, date : String
, entryType : String
}
type alias NewSchedule =
{ dayOfWeek : String
, startTime : String
, endTime : String
, scheduleType : String
, title : String
}
type alias NewUser =
{ username : String
, password : String
, 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
)
-- UPDATE
type Msg
= UpdateUsername String
| UpdatePassword String
| Login
| LoginResponse (Result Http.Error LoginResult)
| Logout
| FetchSchedules
| SchedulesReceived (Result Http.Error (List Schedule))
| ToggleScheduleSelection Int
| SaveTimeEntries
| TimeEntriesSaved (Result Http.Error ())
| UpdateNewScheduleDay String
| UpdateNewScheduleStart String
| UpdateNewScheduleEnd String
| UpdateNewScheduleType String
| UpdateNewScheduleTitle String
| CreateSchedule
| ScheduleCreated (Result Http.Error ())
| DeleteSchedule Int
| ScheduleDeleted (Result Http.Error ())
| UpdateNewUsername String
| UpdateNewPassword String
| UpdateNewUserAdmin Bool
| CreateUser
| UserCreated (Result Http.Error ())
| FetchUsers
| UsersReceived (Result Http.Error (List User))
| FetchAllTimeEntries
| AllTimeEntriesReceived (Result Http.Error (List TimeEntry))
| UpdateCurrentDate String
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
UpdateUsername username ->
({ model | username = username }, Cmd.none)
UpdatePassword password ->
({ model | password = password }, Cmd.none)
Login ->
(model, loginRequest model.username model.password)
LoginResponse (Ok result) ->
let
newPage = if result.isAdmin then AdminDashboard else UserDashboard
in
({ model
| token = Just result.token
, isAdmin = result.isAdmin
, page = newPage
, error = Nothing
}, fetchSchedules (Just result.token))
LoginResponse (Err _) ->
({ model | error = Just "Login fehlgeschlagen" }, Cmd.none)
Logout ->
init ()
FetchSchedules ->
(model, fetchSchedules model.token)
SchedulesReceived (Ok schedules) ->
({ model | schedules = schedules }, Cmd.none)
SchedulesReceived (Err _) ->
({ model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none)
ToggleScheduleSelection scheduleId ->
let
newSelected =
if List.member scheduleId model.selectedEntries then
List.filter (\id -> id /= scheduleId) model.selectedEntries
else
scheduleId :: model.selectedEntries
in
({ model | selectedEntries = newSelected }, Cmd.none)
SaveTimeEntries ->
case model.token of
Just token ->
(model, saveTimeEntries token model.selectedEntries model.currentDate)
Nothing ->
(model, Cmd.none)
TimeEntriesSaved (Ok _) ->
({ model | selectedEntries = [], error = Nothing }, Cmd.none)
TimeEntriesSaved (Err _) ->
({ model | error = Just "Fehler beim Speichern" }, Cmd.none)
UpdateNewScheduleDay day ->
let
oldSchedule = model.newSchedule
newSchedule = { oldSchedule | dayOfWeek = day }
in
({ model | newSchedule = newSchedule }, Cmd.none)
UpdateNewScheduleStart time ->
let
oldSchedule = model.newSchedule
newSchedule = { oldSchedule | startTime = time }
in
({ model | newSchedule = newSchedule }, Cmd.none)
UpdateNewScheduleEnd time ->
let
oldSchedule = model.newSchedule
newSchedule = { oldSchedule | endTime = time }
in
({ model | newSchedule = newSchedule }, Cmd.none)
UpdateNewScheduleType scheduleType ->
let
oldSchedule = model.newSchedule
newSchedule = { oldSchedule | scheduleType = scheduleType }
in
({ model | newSchedule = newSchedule }, Cmd.none)
UpdateNewScheduleTitle title ->
let
oldSchedule = model.newSchedule
newSchedule = { oldSchedule | title = title }
in
({ model | newSchedule = newSchedule }, Cmd.none)
CreateSchedule ->
case model.token of
Just token ->
(model, createSchedule token model.newSchedule)
Nothing ->
(model, Cmd.none)
ScheduleCreated (Ok _) ->
let
emptySchedule = NewSchedule "" "" "" "lesson" ""
in
({ model | newSchedule = emptySchedule }, fetchSchedules model.token)
ScheduleCreated (Err _) ->
({ model | error = Just "Fehler beim Erstellen" }, Cmd.none)
DeleteSchedule scheduleId ->
case model.token of
Just token ->
(model, deleteSchedule token scheduleId)
Nothing ->
(model, Cmd.none)
ScheduleDeleted (Ok _) ->
(model, fetchSchedules model.token)
ScheduleDeleted (Err _) ->
({ model | error = Just "Fehler beim Löschen" }, Cmd.none)
UpdateNewUsername username ->
let
oldUser = model.newUser
newUser = { oldUser | username = username }
in
({ model | newUser = newUser }, Cmd.none)
UpdateNewPassword password ->
let
oldUser = model.newUser
newUser = { oldUser | password = password }
in
({ model | newUser = newUser }, Cmd.none)
UpdateNewUserAdmin isAdmin ->
let
oldUser = model.newUser
newUser = { oldUser | isAdmin = isAdmin }
in
({ model | newUser = newUser }, Cmd.none)
CreateUser ->
case model.token of
Just token ->
(model, createUser token model.newUser)
Nothing ->
(model, Cmd.none)
UserCreated (Ok _) ->
let
emptyUser = NewUser "" "" False
in
case model.token of
Just token ->
({ model | newUser = emptyUser }, fetchUsers token)
Nothing ->
({ model | error = Just "Kein Token vorhanden" }, Cmd.none)
-- ({ model | newUser = emptyUser }, fetchUsers model.token)
UserCreated (Err _) ->
({ model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none)
FetchUsers ->
case model.token of
Just token ->
(model, fetchUsers token)
Nothing ->
(model, Cmd.none)
UsersReceived (Ok users) ->
({ model | users = users }, Cmd.none)
UsersReceived (Err _) ->
({ model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none)
FetchAllTimeEntries ->
case model.token of
Just token ->
(model, fetchAllTimeEntries token)
Nothing ->
(model, Cmd.none)
AllTimeEntriesReceived (Ok entries) ->
({ model | timeEntries = entries }, Cmd.none)
AllTimeEntriesReceived (Err _) ->
({ model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none)
UpdateCurrentDate date ->
({ model | currentDate = date }, Cmd.none)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- VIEW
view : Model -> Html Msg
view model =
div [ class "container" ]
[ case model.page of
LoginPage ->
viewLogin model
UserDashboard ->
viewUserDashboard model
AdminDashboard ->
viewAdminDashboard model
]
viewLogin : Model -> Html Msg
viewLogin model =
section [ class "section" ]
[ div [ class "container" ]
[ div [ class "columns is-centered" ]
[ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
[ div [ class "box" ]
[ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ]
, case model.error of
Just err ->
div [ class "notification is-danger" ] [ text err ]
Nothing ->
text ""
, div [ class "field" ]
[ label [ class "label" ] [ text "Benutzername" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "text"
, placeholder "Benutzername"
, value model.username
, onInput UpdateUsername
] []
]
]
, div [ class "field" ]
[ label [ class "label" ] [ text "Passwort" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "password"
, placeholder "Passwort"
, value model.password
, onInput UpdatePassword
] []
]
]
, div [ class "field" ]
[ div [ class "control" ]
[ button
[ class "button is-primary is-fullwidth"
, onClick Login
] [ text "Anmelden" ]
]
]
]
]
]
]
]
viewUserDashboard : Model -> Html Msg
viewUserDashboard model =
div []
[ nav [ class "navbar is-primary" ]
[ div [ class "navbar-brand" ]
[ div [ class "navbar-item" ]
[ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
]
]
, div [ class "navbar-menu" ]
[ div [ class "navbar-end" ]
[ div [ class "navbar-item" ]
[ 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" ]
[ div [ class "control" ]
[ button
[ class "button is-primary is-large is-fullwidth"
, onClick SaveTimeEntries
, disabled (List.isEmpty model.selectedEntries || String.isEmpty model.currentDate)
] [ text "Speichern" ]
]
]
, case model.error of
Just err ->
div [ class "notification is-danger" ] [ text err ]
Nothing ->
text ""
]
]
]
viewAdminDashboard : Model -> Html Msg
viewAdminDashboard model =
div []
[ nav [ class "navbar is-danger" ]
[ div [ class "navbar-brand" ]
[ div [ class "navbar-item" ]
[ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ]
]
]
, div [ class "navbar-menu" ]
[ div [ class "navbar-end" ]
[ div [ class "navbar-item" ]
[ button [ class "button is-light", onClick Logout ] [ text "Abmelden" ]
]
]
]
]
, section [ class "section" ]
[ 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" ] ]
]
]
, 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 ""
]
]
]
viewScheduleGrid : Model -> Html Msg
viewScheduleGrid model =
let
days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]
groupedSchedules = List.range 0 4
|> List.map (\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)
]
, tbody []
[ tr []
(List.map (viewDayColumn 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 =
let
isSelected = List.member schedule.id model.selectedEntries
boxClass = if isSelected then "box has-background-primary-light" else "box"
typeText = if schedule.scheduleType == "break" then " (Pause)" else ""
in
div
[ class boxClass
, onClick (ToggleScheduleSelection schedule.id)
, style "cursor" "pointer"
, style "margin-bottom" "0.5rem"
]
[ p [ class "has-text-weight-bold" ] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
, p [] [ text (schedule.title ++ typeText) ]
]
viewScheduleForm : Model -> Html Msg
viewScheduleForm model =
div [ class "box" ]
[ div [ class "columns" ]
[ div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Wochentag" ]
, div [ class "control" ]
[ div [ class "select is-fullwidth" ]
[ select [ onInput UpdateNewScheduleDay ]
[ option [ value "" ] [ text "Wochentag wählen" ]
, option [ value "0" ] [ text "Montag" ]
, option [ value "1" ] [ text "Dienstag" ]
, option [ value "2" ] [ text "Mittwoch" ]
, option [ value "3" ] [ text "Donnerstag" ]
, option [ value "4" ] [ text "Freitag" ]
]
]
]
]
]
, div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Startzeit" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "time"
, value model.newSchedule.startTime
, onInput UpdateNewScheduleStart
] []
]
]
]
, div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Endzeit" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "time"
, value model.newSchedule.endTime
, onInput UpdateNewScheduleEnd
] []
]
]
]
]
, div [ class "columns" ]
[ div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Typ" ]
, div [ class "control" ]
[ div [ class "select is-fullwidth" ]
[ select [ onInput UpdateNewScheduleType, value model.newSchedule.scheduleType ]
[ option [ value "lesson" ] [ text "Unterricht" ]
, option [ value "break" ] [ text "Pause" ]
]
]
]
]
]
, div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Titel" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "text"
, placeholder "z.B. Mathematik"
, value model.newSchedule.title
, onInput UpdateNewScheduleTitle
] []
]
]
]
]
, div [ class "field" ]
[ div [ class "control" ]
[ button [ class "button is-primary", onClick CreateSchedule ] [ text "Hinzufügen" ]
]
]
]
viewScheduleList : Model -> Html Msg
viewScheduleList model =
div [ class "box" ]
[ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ]
, table [ class "table is-fullwidth is-striped" ]
[ thead []
[ tr []
[ th [] [ text "Tag" ]
, th [] [ text "Zeit" ]
, th [] [ text "Typ" ]
, th [] [ text "Titel" ]
, th [] [ text "Aktion" ]
]
]
, tbody []
(List.map viewScheduleRow model.schedules)
]
]
viewScheduleRow : Schedule -> Html Msg
viewScheduleRow schedule =
let
dayName = case schedule.dayOfWeek of
0 -> "Montag"
1 -> "Dienstag"
2 -> "Mittwoch"
3 -> "Donnerstag"
4 -> "Freitag"
_ -> "Unbekannt"
typeName = if schedule.scheduleType == "break" then "Pause" else "Unterricht"
in
tr []
[ td [] [ text dayName ]
, td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
, td [] [ text typeName ]
, td [] [ text schedule.title ]
, td []
[ button
[ class "button is-small is-danger"
, onClick (DeleteSchedule schedule.id)
] [ text "Löschen" ]
]
]
viewUserForm : Model -> Html Msg
viewUserForm model =
div [ class "box" ]
[ div [ class "columns" ]
[ div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Benutzername" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "text"
, placeholder "Benutzername"
, value model.newUser.username
, onInput UpdateNewUsername
] []
]
]
]
, div [ class "column" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Passwort" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "password"
, placeholder "Passwort"
, value model.newUser.password
, onInput UpdateNewPassword
] []
]
]
]
, div [ class "column is-narrow" ]
[ div [ class "field" ]
[ label [ class "label" ] [ text "Admin" ]
, div [ class "control" ]
[ label [ class "checkbox" ]
[ input
[ type_ "checkbox"
, checked model.newUser.isAdmin
, onCheck UpdateNewUserAdmin
] []
, text " Admin-Rechte"
]
]
]
]
]
, div [ class "field" ]
[ div [ class "control" ]
[ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ]
]
]
]
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" ]
]
]
, tbody []
(List.map viewUserRow model.users)
]
]
viewUserRow : User -> Html Msg
viewUserRow user =
tr []
[ td [] [ text (String.fromInt user.id) ]
, td [] [ text user.username ]
, td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ]
]
viewTimeEntriesList : Model -> Html Msg
viewTimeEntriesList model =
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" ]
]
]
, tbody []
(List.map viewTimeEntryRow model.timeEntries)
]
]
viewTimeEntryRow : TimeEntry -> Html Msg
viewTimeEntryRow entry =
tr []
[ td [] [ text (String.fromInt entry.userId) ]
, td [] [ text (String.fromInt entry.scheduleId) ]
, td [] [ text entry.date ]
, td [] [ text entry.entryType ]
]
-- HTTP
type alias LoginResult =
{ token : String
, username : String
, isAdmin : Bool
}
loginRequest : String -> String -> Cmd Msg
loginRequest username password =
Http.post
{ url = "/api/login"
, body = Http.jsonBody <|
Encode.object
[ ("username", Encode.string username)
, ("password", Encode.string password)
]
, expect = Http.expectJson LoginResponse loginDecoder
}
loginDecoder : Decoder LoginResult
loginDecoder =
Decode.map3 LoginResult
(field "token" string)
(field "username" string)
(field "is_admin" bool)
fetchSchedules : Maybe String -> Cmd Msg
fetchSchedules maybeToken =
case maybeToken of
Just token ->
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/schedules"
, body = Http.emptyBody
, expect = Http.expectJson SchedulesReceived (Decode.list scheduleDecoder)
, timeout = Nothing
, tracker = Nothing
}
Nothing ->
Cmd.none
scheduleDecoder : Decoder Schedule
scheduleDecoder =
Decode.map6 Schedule
(field "id" int)
(field "day_of_week" int)
(field "start_time" string)
(field "end_time" string)
(field "type" string)
(field "title" string)
saveTimeEntries : String -> List Int -> String -> Cmd Msg
saveTimeEntries token scheduleIds date =
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
in
case List.head requests of
Just cmd -> cmd
Nothing -> Cmd.none
createSchedule : String -> NewSchedule -> Cmd Msg
createSchedule token schedule =
case String.toInt schedule.dayOfWeek of
Just day ->
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/schedules"
, body = Http.jsonBody <|
Encode.object
[ ("day_of_week", Encode.int day)
, ("start_time", Encode.string schedule.startTime)
, ("end_time", Encode.string schedule.endTime)
, ("type", Encode.string schedule.scheduleType)
, ("title", Encode.string schedule.title)
]
, expect = Http.expectWhatever ScheduleCreated
, timeout = Nothing
, tracker = Nothing
}
Nothing ->
Cmd.none
deleteSchedule : String -> Int -> Cmd Msg
deleteSchedule token scheduleId =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId
, body = Http.emptyBody
, expect = Http.expectWhatever ScheduleDeleted
, timeout = Nothing
, tracker = Nothing
}
createUser : String -> NewUser -> Cmd Msg
createUser token user =
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users"
, body = Http.jsonBody <|
Encode.object
[ ("username", Encode.string user.username)
, ("password", Encode.string user.password)
, ("is_admin", Encode.bool user.isAdmin)
]
, expect = Http.expectWhatever UserCreated
, timeout = Nothing
, tracker = Nothing
}
fetchUsers : String -> Cmd Msg
fetchUsers token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users/list"
, body = Http.emptyBody
, expect = Http.expectJson UsersReceived (Decode.list userDecoder)
, timeout = Nothing
, tracker = Nothing
}
userDecoder : Decoder User
userDecoder =
Decode.map3 User
(field "id" int)
(field "username" string)
(field "is_admin" bool)
fetchAllTimeEntries : String -> Cmd Msg
fetchAllTimeEntries token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/time-entries"
, body = Http.emptyBody
, expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder)
, timeout = Nothing
, tracker = Nothing
}
timeEntryDecoder : Decoder TimeEntry
timeEntryDecoder =
Decode.map5 TimeEntry
(field "id" int)
(field "user_id" int)
(field "schedule_id" int)
(field "date" string)
(field "type" string)