feat: add Logging Middleware, saving Token and better Handling
This commit is contained in:
parent
2c4fc7869a
commit
d74046522b
8 changed files with 926 additions and 236 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
11
frontend/src/Ports.elm
Normal file
11
frontend/src/Ports.elm
Normal 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
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue