fix: Add Mobile View and fix error while freeze on entering new schedule
This commit is contained in:
parent
5001cc1147
commit
9c25956711
6 changed files with 746 additions and 392 deletions
|
|
@ -24,6 +24,7 @@ func InitDB(filepath string) *sql.DB {
|
|||
}
|
||||
|
||||
createTables(db)
|
||||
createIndexes(db)
|
||||
return db
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +35,8 @@ func createTables(db *sql.DB) {
|
|||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||
weekly_hours REAL NOT NULL DEFAULT 40.0
|
||||
weekly_hours REAL NOT NULL DEFAULT 40.0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -42,7 +44,8 @@ func createTables(db *sql.DB) {
|
|||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL
|
||||
title TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -56,6 +59,13 @@ func createTables(db *sql.DB) {
|
|||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (schedule_id) REFERENCES schedules(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
|
|
@ -64,6 +74,7 @@ func createTables(db *sql.DB) {
|
|||
}
|
||||
}
|
||||
|
||||
// Admin-User anlegen
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
_, err := db.Exec(`
|
||||
INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours)
|
||||
|
|
@ -75,52 +86,21 @@ func createTables(db *sql.DB) {
|
|||
}
|
||||
}
|
||||
|
||||
// func createTables(db *sql.DB) {
|
||||
// queries := []string{
|
||||
// `CREATE TABLE IF NOT EXISTS users (
|
||||
// id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
// username TEXT UNIQUE NOT NULL,
|
||||
// password TEXT NOT NULL,
|
||||
// is_admin BOOLEAN NOT NULL DEFAULT 0
|
||||
// )`,
|
||||
// `CREATE TABLE IF NOT EXISTS schedules (
|
||||
// id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
// day_of_week INTEGER NOT NULL,
|
||||
// start_time TEXT NOT NULL,
|
||||
// end_time TEXT NOT NULL,
|
||||
// type TEXT NOT NULL,
|
||||
// title TEXT NOT NULL
|
||||
// )`,
|
||||
// `CREATE TABLE IF NOT EXISTS time_entries (
|
||||
// id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
// user_id INTEGER NOT NULL,
|
||||
// schedule_id INTEGER NOT NULL,
|
||||
// date TEXT NOT NULL,
|
||||
// type TEXT NOT NULL,
|
||||
// start_time TEXT NOT NULL,
|
||||
// end_time TEXT NOT NULL,
|
||||
// created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
// FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
// FOREIGN KEY (schedule_id) REFERENCES schedules(id)
|
||||
// )`,
|
||||
// }
|
||||
func createIndexes(db *sql.DB) {
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
|
||||
}
|
||||
|
||||
// for _, query := range queries {
|
||||
// if _, err := db.Exec(query); err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// }
|
||||
|
||||
// hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
// _, err := db.Exec(`
|
||||
// INSERT OR IGNORE INTO users (id, username, password, is_admin)
|
||||
// VALUES (?, ?, ?, ?)`,
|
||||
// 1, "admin", string(hash), true,
|
||||
// )
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// }
|
||||
for _, idx := range indexes {
|
||||
if _, err := db.Exec(idx); err != nil {
|
||||
log.Printf("Warning: Failed to create index: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||
user := &User{}
|
||||
|
|
@ -149,7 +129,7 @@ func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, weekl
|
|||
}
|
||||
|
||||
func GetAllUsers(db *sql.DB) ([]User, error) {
|
||||
rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users")
|
||||
rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users ORDER BY username")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -166,40 +146,6 @@ func GetAllUsers(db *sql.DB) ([]User, error) {
|
|||
return users, nil
|
||||
}
|
||||
|
||||
// func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||
// user := &User{}
|
||||
// err := db.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username).
|
||||
// Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return user, nil
|
||||
// }
|
||||
|
||||
// func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool) error {
|
||||
// _, err := db.Exec("INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
||||
// username, hashedPassword, isAdmin)
|
||||
// return err
|
||||
// }
|
||||
|
||||
// func GetAllUsers(db *sql.DB) ([]User, error) {
|
||||
// rows, err := db.Query("SELECT id, username, is_admin FROM users")
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
// var users []User
|
||||
// for rows.Next() {
|
||||
// var u User
|
||||
// if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin); err != nil {
|
||||
// continue
|
||||
// }
|
||||
// users = append(users, u)
|
||||
// }
|
||||
// return users, nil
|
||||
// }
|
||||
|
||||
func UpdateUser(db *sql.DB, userID int, weeklyHours float64) error {
|
||||
_, err := db.Exec("UPDATE users SET weekly_hours = ? WHERE id = ?",
|
||||
weeklyHours, userID)
|
||||
|
|
@ -395,74 +341,6 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
|
||||
// rows, err := db.Query(`
|
||||
// SELECT
|
||||
// te.user_id,
|
||||
// u.username,
|
||||
// te.date,
|
||||
// te.start_time,
|
||||
// te.end_time
|
||||
// FROM time_entries te
|
||||
// JOIN users u ON te.user_id = u.id
|
||||
// ORDER BY te.date DESC
|
||||
// `)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
// hoursMap := make(map[string]*WeeklyHours)
|
||||
|
||||
// for rows.Next() {
|
||||
// var userID int
|
||||
// var username, dateStr, startTime, endTime string
|
||||
|
||||
// if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime); err != nil {
|
||||
// continue
|
||||
// }
|
||||
|
||||
// t, err := time.Parse("2006-01-02", dateStr)
|
||||
// if err != nil {
|
||||
// continue
|
||||
// }
|
||||
|
||||
// year, week := t.ISOWeek()
|
||||
|
||||
// hours := calculateHoursDiff(startTime, endTime)
|
||||
|
||||
// key := fmt.Sprintf("%d_%d_%d", userID, year, week)
|
||||
|
||||
// if existing, exists := hoursMap[key]; exists {
|
||||
// existing.TotalHours += hours
|
||||
// } else {
|
||||
// hoursMap[key] = &WeeklyHours{
|
||||
// UserID: userID,
|
||||
// Username: username,
|
||||
// Year: year,
|
||||
// Week: week,
|
||||
// TotalHours: hours,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// var result []WeeklyHours
|
||||
// for _, h := range hoursMap {
|
||||
// result = append(result, *h)
|
||||
// }
|
||||
|
||||
// sort.Slice(result, func(i, j int) bool {
|
||||
// if result[i].Year != result[j].Year {
|
||||
// return result[i].Year > result[j].Year
|
||||
// }
|
||||
// if result[i].Week != result[j].Week {
|
||||
// return result[i].Week > result[j].Week
|
||||
// }
|
||||
// return result[i].Username < result[j].Username
|
||||
// })
|
||||
|
||||
// return result, nil
|
||||
// }
|
||||
func calculateHoursDiff(startTime, endTime string) float64 {
|
||||
parseTime := func(timeStr string) float64 {
|
||||
parts := strings.Split(timeStr, ":")
|
||||
|
|
@ -489,14 +367,6 @@ func calculateHoursDiff(startTime, endTime string) float64 {
|
|||
return 0
|
||||
}
|
||||
|
||||
// func DeleteUser(db *sql.DB, id int) error {
|
||||
// if id == 1 {
|
||||
// return fmt.Errorf("cannot delete admin user")
|
||||
// }
|
||||
// _, err := db.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
// return err
|
||||
// }
|
||||
|
||||
func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error {
|
||||
dates := calculateWeekDates(year, week)
|
||||
|
||||
|
|
|
|||
22
backend/load-env.sh
Executable file
22
backend/load-env.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
echo "✅ .env geladen"
|
||||
else
|
||||
echo "❌ .env Datei nicht gefunden!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PORT" ]; then
|
||||
export PORT=8080
|
||||
fi
|
||||
|
||||
if [ -z "$DB_PATH" ]; then
|
||||
export DB_PATH="/data/timetracking.db"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
|
|
@ -7,14 +7,25 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("your-secret-key-change-in-production")
|
||||
var jwtSecret []byte
|
||||
|
||||
func init() {
|
||||
secret := os.Getenv("JWT_SECRET")
|
||||
if secret == "" {
|
||||
panic("JWT_SECRET environment variable is required")
|
||||
}
|
||||
jwtSecret = []byte(secret)
|
||||
}
|
||||
|
||||
func createToken(userID int, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
|
|
@ -29,7 +40,7 @@ func createToken(userID int, username string, isAdmin bool) (string, error) {
|
|||
"user_id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"is_admin": claims.IsAdmin,
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
"exp": time.Now().Add(2 * time.Hour).Unix(),
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(claimsWithExp)
|
||||
|
|
@ -89,13 +100,13 @@ func JWTMiddleware() echo.MiddlewareFunc {
|
|||
return func(c echo.Context) error {
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := verifyToken(tokenString)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
|
|
@ -114,7 +125,7 @@ func AdminMiddleware() echo.MiddlewareFunc {
|
|||
return func(c echo.Context) error {
|
||||
isAdmin, ok := c.Get("is_admin").(bool)
|
||||
if !ok || !isAdmin {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "admin access required")
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Access denied")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
|
|
@ -126,3 +137,69 @@ func CustomLogger() echo.MiddlewareFunc {
|
|||
Format: "${time_rfc3339} | ${status} | ${latency_human} | ${method} ${uri}\n",
|
||||
})
|
||||
}
|
||||
|
||||
type LoginRateLimiter struct {
|
||||
limiters map[string]*rate.Limiter
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewLoginRateLimiter() *LoginRateLimiter {
|
||||
limiter := &LoginRateLimiter{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
limiter.mu.Lock()
|
||||
limiter.limiters = make(map[string]*rate.Limiter)
|
||||
limiter.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
func (l *LoginRateLimiter) GetLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
limiter, exists := l.limiters[ip]
|
||||
if !exists {
|
||||
limiter = rate.NewLimiter(rate.Every(time.Minute/5), 5)
|
||||
l.limiters[ip] = limiter
|
||||
}
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
func (l *LoginRateLimiter) Middleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ip := c.RealIP()
|
||||
limiter := l.GetLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
return echo.NewHTTPError(http.StatusTooManyRequests, "Too many login attempts. Please try again later.")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HTTPSRedirectMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// Nur in Production aktivieren
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
if c.Request().Header.Get("X-Forwarded-Proto") != "https" {
|
||||
return c.Redirect(http.StatusMovedPermanently,
|
||||
"https://"+c.Request().Host+c.Request().RequestURI)
|
||||
}
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,35 +2,148 @@
|
|||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Schulzeit Erfassung</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Zeiterfassung</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.level {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-left, .level-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-spinner {
|
||||
animation: fa-spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="elm"></div>
|
||||
|
||||
<script src="/elm.js"></script>
|
||||
<script>
|
||||
var storedToken = localStorage.getItem('authToken');
|
||||
function getStoredData() {
|
||||
try {
|
||||
const data = localStorage.getItem('timetracking');
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored data:', e);
|
||||
}
|
||||
return { token: null, isAdmin: false };
|
||||
}
|
||||
|
||||
var app = Elm.Main.init({
|
||||
node: document.getElementById('app'),
|
||||
flags: storedToken
|
||||
function saveData(token, isAdmin) {
|
||||
try {
|
||||
localStorage.setItem('timetracking', JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to save data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearData() {
|
||||
try {
|
||||
localStorage.removeItem('timetracking');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const storedData = getStoredData();
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById('elm'),
|
||||
flags: {
|
||||
token: storedData.token,
|
||||
isAdmin: storedData.isAdmin
|
||||
}
|
||||
});
|
||||
|
||||
// Save token to localStorage
|
||||
app.ports.saveToken.subscribe(function(token) {
|
||||
localStorage.setItem('authToken', token);
|
||||
|
||||
app.ports.saveToken.subscribe(function(data) {
|
||||
saveData(data.token, data.isAdmin);
|
||||
});
|
||||
|
||||
// Remove token from localStorage
|
||||
|
||||
app.ports.removeToken.subscribe(function() {
|
||||
localStorage.removeItem('authToken');
|
||||
clearData();
|
||||
});
|
||||
|
||||
app.ports.confirmDelete.subscribe(function(message) {
|
||||
const confirmed = confirm(message);
|
||||
app.ports.confirmDeleteResponse.send(confirmed);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
function setupBurgerMenu() {
|
||||
const burgers = document.querySelectorAll('.navbar-burger');
|
||||
|
||||
burgers.forEach(burger => {
|
||||
burger.addEventListener('click', () => {
|
||||
const target = burger.dataset.target;
|
||||
const menu = document.getElementById(target);
|
||||
|
||||
if (menu) {
|
||||
burger.classList.toggle('is-active');
|
||||
menu.classList.toggle('is-active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupBurgerMenu();
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
setupBurgerMenu();
|
||||
});
|
||||
|
||||
observer.observe(document.getElementById('elm'), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
});
|
||||
|
||||
if ('serviceWorker' in navigator && window.location.protocol === 'https:') {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
console.log('Service Worker registration failed');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,35 +2,163 @@
|
|||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Schulzeit Erfassung</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Zeiterfassung</title>
|
||||
|
||||
<!-- Bulma CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
/* Custom Styles */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Responsive Verbesserungen */
|
||||
@media screen and (max-width: 768px) {
|
||||
.level {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-left, .level-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.fa-spinner {
|
||||
animation: fa-spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="elm"></div>
|
||||
|
||||
<script src="/elm.js"></script>
|
||||
<script>
|
||||
var storedToken = localStorage.getItem('authToken');
|
||||
// LocalStorage Helper
|
||||
function getStoredData() {
|
||||
try {
|
||||
const data = localStorage.getItem('timetracking');
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored data:', e);
|
||||
}
|
||||
return { token: null, isAdmin: false };
|
||||
}
|
||||
|
||||
var app = Elm.Main.init({
|
||||
node: document.getElementById('app'),
|
||||
flags: storedToken
|
||||
function saveData(token, isAdmin) {
|
||||
try {
|
||||
localStorage.setItem('timetracking', JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to save data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearData() {
|
||||
try {
|
||||
localStorage.removeItem('timetracking');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialisiere Elm App mit gespeicherten Daten
|
||||
const storedData = getStoredData();
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById('elm'),
|
||||
flags: {
|
||||
token: storedData.token,
|
||||
isAdmin: storedData.isAdmin
|
||||
}
|
||||
});
|
||||
|
||||
// Save token to localStorage
|
||||
app.ports.saveToken.subscribe(function(token) {
|
||||
localStorage.setItem('authToken', token);
|
||||
|
||||
// Port: Token speichern
|
||||
app.ports.saveToken.subscribe(function(data) {
|
||||
saveData(data.token, data.isAdmin);
|
||||
});
|
||||
|
||||
// Remove token from localStorage
|
||||
|
||||
// Port: Token entfernen
|
||||
app.ports.removeToken.subscribe(function() {
|
||||
localStorage.removeItem('authToken');
|
||||
clearData();
|
||||
});
|
||||
|
||||
// Port: Lösch-Bestätigung
|
||||
app.ports.confirmDelete.subscribe(function(message) {
|
||||
const confirmed = confirm(message);
|
||||
app.ports.confirmDeleteResponse.send(confirmed);
|
||||
});
|
||||
|
||||
// BUGFIX: Responsive Navbar Toggle
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Funktion für Burger-Menu
|
||||
function setupBurgerMenu() {
|
||||
const burgers = document.querySelectorAll('.navbar-burger');
|
||||
|
||||
burgers.forEach(burger => {
|
||||
burger.addEventListener('click', () => {
|
||||
const target = burger.dataset.target;
|
||||
const menu = document.getElementById(target);
|
||||
|
||||
if (menu) {
|
||||
burger.classList.toggle('is-active');
|
||||
menu.classList.toggle('is-active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
setupBurgerMenu();
|
||||
|
||||
// Observer für dynamische Änderungen (wenn Elm DOM updated)
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
setupBurgerMenu();
|
||||
});
|
||||
|
||||
observer.observe(document.getElementById('elm'), {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
});
|
||||
|
||||
// Service Worker für Offline-Fähigkeit (optional)
|
||||
if ('serviceWorker' in navigator && window.location.protocol === 'https:') {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
console.log('Service Worker registration failed');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,15 +14,14 @@ import Dict exposing (Dict)
|
|||
|
||||
-- PORTS
|
||||
|
||||
port saveToken : String -> Cmd msg
|
||||
port saveToken : Encode.Value -> Cmd msg
|
||||
port removeToken : () -> Cmd msg
|
||||
|
||||
port confirmDelete : String -> Cmd msg
|
||||
port confirmDeleteResponse : (Bool -> msg) -> Sub msg
|
||||
|
||||
-- MAIN
|
||||
|
||||
main : Program (Maybe String) Model Msg
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.element
|
||||
{ init = init
|
||||
|
|
@ -31,6 +30,11 @@ main =
|
|||
, view = view
|
||||
}
|
||||
|
||||
-- FLAGS
|
||||
type alias Flags =
|
||||
{ token : Maybe String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
-- MODEL
|
||||
|
||||
|
|
@ -56,17 +60,19 @@ type alias Model =
|
|||
, error : Maybe String
|
||||
, weekEditMode : Bool
|
||||
, hasEntriesForCurrentWeek : Bool
|
||||
, userWeeklySummary : Maybe WeeklySummary -- NEU
|
||||
, editingTimeEntryId : Maybe Int -- NEU
|
||||
, editingTimeEntry : EditingTimeEntry -- NEU
|
||||
, editingUserId : Maybe Int -- NEU
|
||||
, editingUserWorkHours : String -- NEU
|
||||
, resetPasswordUserId : Maybe Int -- NEU
|
||||
, resetPasswordNew : String -- NEU
|
||||
, pendingDeleteId : Maybe Int -- NEU: Speichert die ID die gelöscht werden soll
|
||||
, selectedUserId : Maybe Int -- NEU
|
||||
, userWeeklySummary : Maybe WeeklySummary
|
||||
, editingTimeEntryId : Maybe Int
|
||||
, editingTimeEntry : EditingTimeEntry
|
||||
, editingUserId : Maybe Int
|
||||
, editingUserWorkHours : String
|
||||
, resetPasswordUserId : Maybe Int
|
||||
, resetPasswordNew : String
|
||||
, pendingDeleteId : Maybe Int
|
||||
, selectedUserId : Maybe Int
|
||||
, userWorkHoursInput : String
|
||||
, userPasswordInput : String
|
||||
, isProcessing : Bool
|
||||
, mobileMenuOpen : Bool
|
||||
}
|
||||
|
||||
type Page
|
||||
|
|
@ -92,7 +98,7 @@ type alias User =
|
|||
{ id : Int
|
||||
, username : String
|
||||
, isAdmin : Bool
|
||||
, weeklyWorkHours : Float -- NEU
|
||||
, weeklyWorkHours : Float
|
||||
}
|
||||
|
||||
type alias TimeEntry =
|
||||
|
|
@ -160,16 +166,23 @@ type alias WeeklyHours =
|
|||
, remainingHours : Float
|
||||
}
|
||||
|
||||
init : Maybe String -> (Model, Cmd Msg)
|
||||
init storedToken =
|
||||
init : Flags -> (Model, Cmd Msg)
|
||||
init flags =
|
||||
let
|
||||
initialPage =
|
||||
case flags.token of
|
||||
Just _ ->
|
||||
if flags.isAdmin then AdminDashboard else UserDashboard
|
||||
Nothing ->
|
||||
LoginPage
|
||||
|
||||
model =
|
||||
{ page = if storedToken /= Nothing then UserDashboard else LoginPage
|
||||
{ page = initialPage
|
||||
, activeTab = ScheduleTab
|
||||
, username = ""
|
||||
, password = ""
|
||||
, token = storedToken
|
||||
, isAdmin = False
|
||||
, token = flags.token
|
||||
, isAdmin = flags.isAdmin
|
||||
, schedules = []
|
||||
, users = []
|
||||
, timeEntries = []
|
||||
|
|
@ -185,21 +198,23 @@ init storedToken =
|
|||
, weekEditMode = False
|
||||
, hasEntriesForCurrentWeek = False
|
||||
, weekDates = Nothing
|
||||
, userWeeklySummary = Nothing -- NEU
|
||||
, editingTimeEntryId = Nothing -- NEU
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "" -- NEU
|
||||
, editingUserId = Nothing -- NEU
|
||||
, editingUserWorkHours = "" -- NEU
|
||||
, resetPasswordUserId = Nothing -- NEU
|
||||
, resetPasswordNew = "" -- NEU
|
||||
, pendingDeleteId = Nothing -- NEU!
|
||||
, selectedUserId = Nothing -- NEU
|
||||
, userWeeklySummary = Nothing
|
||||
, editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
|
||||
, editingUserId = Nothing
|
||||
, editingUserWorkHours = ""
|
||||
, resetPasswordUserId = Nothing
|
||||
, resetPasswordNew = ""
|
||||
, pendingDeleteId = Nothing
|
||||
, selectedUserId = Nothing
|
||||
, userWorkHoursInput = ""
|
||||
, userPasswordInput = ""
|
||||
, isProcessing = False
|
||||
, mobileMenuOpen = False
|
||||
}
|
||||
|
||||
cmd =
|
||||
case storedToken of
|
||||
case flags.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Task.perform SetTime Time.now
|
||||
|
|
@ -259,30 +274,30 @@ type Msg
|
|||
| WeekDatesReceived (Result Http.Error WeekDates)
|
||||
| CheckWeekHasEntries
|
||||
| WeekHasEntriesReceived (Result Http.Error Bool)
|
||||
| FetchMyWeeklySummary -- NEU
|
||||
| MyWeeklySummaryReceived (Result Http.Error WeeklySummary) -- NEU
|
||||
| EditTimeEntry Int -- NEU
|
||||
| CancelEditTimeEntry -- NEU
|
||||
| UpdateEditTimeEntryDate String -- NEU
|
||||
| UpdateEditTimeEntryStartTime String -- NEU
|
||||
| UpdateEditTimeEntryEndTime String -- NEU
|
||||
| UpdateEditTimeEntryType String -- NEU
|
||||
| SaveEditTimeEntry -- NEU
|
||||
| TimeEntrySaved (Result Http.Error ()) -- NEU
|
||||
| TimeEntryDeleted (Result Http.Error ()) -- NEU
|
||||
| EditUserWorkHours Int -- NEU
|
||||
| CancelEditUserWorkHours -- NEU
|
||||
| UpdateEditUserWorkHours String -- NEU
|
||||
| SaveUserWorkHours -- NEU
|
||||
| UserWorkHoursSaved (Result Http.Error ()) -- NEU
|
||||
| ResetUserPassword Int -- NEU
|
||||
| CancelResetPassword -- NEU
|
||||
| UpdateResetPasswordNew String -- NEU
|
||||
| SaveResetPassword -- NEU
|
||||
| ResetPasswordSaved (Result Http.Error ()) -- NEU
|
||||
| ConfirmDeleteTimeEntry Int -- NEU
|
||||
| ConfirmDeleteUser Int -- NEU
|
||||
| DeleteConfirmed Bool -- NEU
|
||||
| FetchMyWeeklySummary
|
||||
| MyWeeklySummaryReceived (Result Http.Error WeeklySummary)
|
||||
| EditTimeEntry Int
|
||||
| CancelEditTimeEntry
|
||||
| UpdateEditTimeEntryDate String
|
||||
| UpdateEditTimeEntryStartTime String
|
||||
| UpdateEditTimeEntryEndTime String
|
||||
| UpdateEditTimeEntryType String
|
||||
| SaveEditTimeEntry
|
||||
| TimeEntrySaved (Result Http.Error ())
|
||||
| TimeEntryDeleted (Result Http.Error ())
|
||||
| EditUserWorkHours Int
|
||||
| CancelEditUserWorkHours
|
||||
| UpdateEditUserWorkHours String
|
||||
| SaveUserWorkHours
|
||||
| UserWorkHoursSaved (Result Http.Error ())
|
||||
| ResetUserPassword Int
|
||||
| CancelResetPassword
|
||||
| UpdateResetPasswordNew String
|
||||
| SaveResetPassword
|
||||
| ResetPasswordSaved (Result Http.Error ())
|
||||
| ConfirmDeleteTimeEntry Int
|
||||
| ConfirmDeleteUser Int
|
||||
| DeleteConfirmed Bool
|
||||
| StartEditingTimeEntry Int TimeEntry
|
||||
| CancelEditingTimeEntry
|
||||
| UpdateEditingTimeEntryDate String
|
||||
|
|
@ -295,10 +310,18 @@ type Msg
|
|||
| UpdateUserPassword String
|
||||
| SaveUserPassword
|
||||
| UserPasswordSaved (Result Http.Error ())
|
||||
| ToggleMobileMenu
|
||||
| CloseMobileMenu
|
||||
|
||||
update : Msg -> Model -> (Model, Cmd Msg)
|
||||
update msg model =
|
||||
case msg of
|
||||
ToggleMobileMenu ->
|
||||
({ model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none)
|
||||
|
||||
CloseMobileMenu ->
|
||||
({ model | mobileMenuOpen = False }, Cmd.none)
|
||||
|
||||
UpdateUsername username ->
|
||||
({ model | username = username }, Cmd.none)
|
||||
|
||||
|
|
@ -306,13 +329,21 @@ update msg model =
|
|||
({ model | password = password }, Cmd.none)
|
||||
|
||||
Login ->
|
||||
(model, loginRequest model.username model.password)
|
||||
if model.isProcessing then
|
||||
(model, Cmd.none)
|
||||
else
|
||||
({ model | isProcessing = True }, loginRequest model.username model.password)
|
||||
|
||||
LoginResponse (Ok result) ->
|
||||
let
|
||||
newPage = if result.isAdmin then AdminDashboard else UserDashboard
|
||||
|
||||
(year, week) = getISOWeekFromPosix model.currentTime
|
||||
|
||||
tokenData = Encode.object
|
||||
[ ("token", Encode.string result.token)
|
||||
, ("isAdmin", Encode.bool result.isAdmin)
|
||||
]
|
||||
in
|
||||
({ model
|
||||
| token = Just result.token
|
||||
|
|
@ -320,27 +351,28 @@ update msg model =
|
|||
, isAdmin = result.isAdmin
|
||||
, page = newPage
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}, Cmd.batch
|
||||
[ saveToken result.token
|
||||
[ saveToken tokenData
|
||||
, fetchSchedules (Just result.token)
|
||||
, if not result.isAdmin then
|
||||
Cmd.batch
|
||||
[ fetchMyTimeEntries result.token
|
||||
, fetchWeekDates result.token year week
|
||||
, checkWeekHasEntries result.token year week
|
||||
, fetchMyWeeklySummary result.token year week -- NEU!
|
||||
, fetchMyWeeklySummary result.token year week
|
||||
]
|
||||
else
|
||||
Cmd.batch
|
||||
[ fetchMyTimeEntries result.token
|
||||
, fetchWeekDates result.token year week
|
||||
, checkWeekHasEntries result.token year week
|
||||
, fetchMyWeeklySummary result.token year week -- NEU!
|
||||
, fetchMyWeeklySummary result.token year week
|
||||
]
|
||||
])
|
||||
|
||||
LoginResponse (Err _) ->
|
||||
({ model | error = Just "Login fehlgeschlagen" }, Cmd.none)
|
||||
({ model | error = Just "Login fehlgeschlagen", isProcessing = False }, Cmd.none)
|
||||
|
||||
Logout ->
|
||||
({ model
|
||||
|
|
@ -349,6 +381,7 @@ update msg model =
|
|||
, isAdmin = False
|
||||
, username = ""
|
||||
, password = ""
|
||||
, isProcessing = False
|
||||
}, removeToken ())
|
||||
|
||||
FetchSchedules ->
|
||||
|
|
@ -389,7 +422,7 @@ update msg model =
|
|||
, hasEntriesForCurrentWeek = True
|
||||
}, Cmd.batch
|
||||
[ fetchMyTimeEntries token
|
||||
, fetchMyWeeklySummary token model.currentYear model.currentWeek -- NEU!
|
||||
, fetchMyWeeklySummary token model.currentYear model.currentWeek
|
||||
])
|
||||
Nothing ->
|
||||
(model, Cmd.none)
|
||||
|
|
@ -411,7 +444,7 @@ update msg model =
|
|||
Cmd.batch
|
||||
[ fetchWeekDates token newYear newWeek
|
||||
, checkWeekHasEntries token newYear newWeek
|
||||
, fetchMyWeeklySummary token newYear newWeek -- NEU!
|
||||
, fetchMyWeeklySummary token newYear newWeek
|
||||
]
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
|
@ -431,7 +464,7 @@ update msg model =
|
|||
Cmd.batch
|
||||
[ fetchWeekDates token newYear newWeek
|
||||
, checkWeekHasEntries token newYear newWeek
|
||||
, fetchMyWeeklySummary token newYear newWeek -- NEU!
|
||||
, fetchMyWeeklySummary token newYear newWeek
|
||||
]
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
|
@ -474,7 +507,7 @@ update msg model =
|
|||
[ checkWeekHasEntries token year week
|
||||
, fetchWeekDates token year week
|
||||
, fetchMyTimeEntries token
|
||||
, fetchMyWeeklySummary token year week -- NEU!
|
||||
, fetchMyWeeklySummary token year week
|
||||
]
|
||||
else
|
||||
Cmd.none
|
||||
|
|
@ -564,7 +597,7 @@ update msg model =
|
|||
_ ->
|
||||
Cmd.none
|
||||
in
|
||||
({ model | activeTab = tab }, cmd)
|
||||
({ model | activeTab = tab, mobileMenuOpen = False}, cmd)
|
||||
|
||||
UpdateNewScheduleDay day ->
|
||||
let
|
||||
|
|
@ -602,20 +635,45 @@ update msg model =
|
|||
({ model | newSchedule = newSchedule }, Cmd.none)
|
||||
|
||||
CreateSchedule ->
|
||||
if String.isEmpty model.newSchedule.dayOfWeek ||
|
||||
String.isEmpty model.newSchedule.startTime ||
|
||||
String.isEmpty model.newSchedule.endTime then
|
||||
({ model | error = Just "Bitte alle Felder ausfüllen" }, Cmd.none)
|
||||
else
|
||||
case model.token of
|
||||
Just token ->
|
||||
({ model | isProcessing = True }, createSchedule token model.newSchedule)
|
||||
Nothing ->
|
||||
(model, Cmd.none)
|
||||
|
||||
ScheduleCreated (Ok _) ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
(model, createSchedule token model.newSchedule)
|
||||
let
|
||||
emptySchedule = NewSchedule "" "" "" "lesson" ""
|
||||
in
|
||||
({ model
|
||||
| newSchedule = emptySchedule
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}, fetchSchedules model.token)
|
||||
|
||||
Nothing ->
|
||||
(model, Cmd.none)
|
||||
|
||||
ScheduleCreated (Ok _) ->
|
||||
ScheduleCreated (Err err) ->
|
||||
let
|
||||
emptySchedule = NewSchedule "" "" "" "lesson" ""
|
||||
errorMsg = case err of
|
||||
Http.BadStatus 400 -> "Ungültige Eingabe"
|
||||
Http.BadStatus 409 -> "Dieser Stundenplan existiert bereits"
|
||||
Http.Timeout -> "Anfrage abgelaufen"
|
||||
Http.NetworkError -> "Netzwerkfehler"
|
||||
_ -> "Fehler beim Erstellen"
|
||||
in
|
||||
({ model | newSchedule = emptySchedule }, fetchSchedules model.token)
|
||||
|
||||
ScheduleCreated (Err _) ->
|
||||
({ model | error = Just "Fehler beim Erstellen" }, Cmd.none)
|
||||
({ model
|
||||
| error = Just errorMsg
|
||||
, isProcessing = False
|
||||
}, Cmd.none)
|
||||
|
||||
DeleteSchedule scheduleId ->
|
||||
case model.token of
|
||||
|
|
@ -989,7 +1047,7 @@ update msg model =
|
|||
({ model | userWorkHoursInput = input }, Cmd.none)
|
||||
|
||||
SaveUserWorkHours ->
|
||||
case (model.token, model.editingUserId, String.toFloat model.editingUserWorkHours) of -- ← Änderungen!
|
||||
case (model.token, model.editingUserId, String.toFloat model.editingUserWorkHours) of
|
||||
(Just token, Just userId, Just hours) ->
|
||||
(model, updateUserWorkHours token userId (String.fromFloat hours))
|
||||
_ ->
|
||||
|
|
@ -999,28 +1057,12 @@ update msg model =
|
|||
case model.token of
|
||||
Just token ->
|
||||
({ model
|
||||
| editingUserWorkHours = "" -- ← Änderung
|
||||
, editingUserId = Nothing -- ← Änderung
|
||||
| editingUserWorkHours = ""
|
||||
, editingUserId = Nothing
|
||||
, error = Nothing
|
||||
}, fetchUsers token)
|
||||
Nothing ->
|
||||
(model, Cmd.none)
|
||||
-- SaveUserWorkHours ->
|
||||
-- case (model.token, model.selectedUserId, String.toFloat model.userWorkHoursInput) of
|
||||
-- (Just token, Just userId, Just hours) ->
|
||||
-- (model, updateUserWorkHours token userId (String.fromFloat hours))
|
||||
-- _ ->
|
||||
-- ({ model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none)
|
||||
|
||||
-- UserWorkHoursSaved (Ok _) ->
|
||||
-- case model.token of
|
||||
-- Just token ->
|
||||
-- ({ model
|
||||
-- | userWorkHoursInput = ""
|
||||
-- , error = Nothing
|
||||
-- }, fetchUsers token)
|
||||
-- Nothing ->
|
||||
-- (model, Cmd.none)
|
||||
|
||||
UserWorkHoursSaved (Err _) ->
|
||||
({ model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none)
|
||||
|
|
@ -1055,7 +1097,7 @@ update msg model =
|
|||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
confirmDeleteResponse DeleteConfirmed -- NEU
|
||||
confirmDeleteResponse DeleteConfirmed
|
||||
|
||||
|
||||
-- HELPER FUNCTIONS
|
||||
|
|
@ -1324,18 +1366,38 @@ viewLogin model =
|
|||
|
||||
viewUserDashboard : Model -> Html Msg
|
||||
viewUserDashboard model =
|
||||
div []
|
||||
div []
|
||||
[ nav [ class "navbar is-primary" ]
|
||||
[ div [ class "navbar-brand" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
|
||||
]
|
||||
, a
|
||||
[ class ("navbar-burger" ++ (if model.mobileMenuOpen then " is-active" else ""))
|
||||
, attribute "role" "navigation"
|
||||
, attribute "aria-label" "menu"
|
||||
, attribute "aria-expanded" (if model.mobileMenuOpen then "true" else "false")
|
||||
, onClick ToggleMobileMenu
|
||||
]
|
||||
[ span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
]
|
||||
]
|
||||
, div
|
||||
[ id "navbarUser"
|
||||
, class ("navbar-menu" ++ (if model.mobileMenuOpen then " is-active" else "")) -- NEU!
|
||||
]
|
||||
, div [ class "navbar-menu" ]
|
||||
[ div [ class "navbar-end" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ span [ class "has-text-white mr-4" ] [ text model.username ]
|
||||
, button [ class "button is-light", onClick Logout ] [ text "Abmelden" ]
|
||||
[ span [ class "has-text-white mr-2" ] [ text model.username ]
|
||||
]
|
||||
, div [ class "navbar-item" ]
|
||||
[ button [ class "button is-light", onClick Logout ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-sign-out-alt" ] [] ]
|
||||
, span [] [ text "Abmelden" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
|
@ -1360,6 +1422,7 @@ viewUserDashboard model =
|
|||
[ button
|
||||
[ class "button is-warning"
|
||||
, onClick EnableEditMode
|
||||
, disabled model.isProcessing
|
||||
] [ text "Bearbeiten" ]
|
||||
]
|
||||
]
|
||||
|
|
@ -1380,6 +1443,7 @@ viewUserDashboard model =
|
|||
[ button
|
||||
[ class "button is-danger is-small mr-2"
|
||||
, onClick DeleteWeekEntries
|
||||
, disabled model.isProcessing
|
||||
] [ text "Einträge löschen" ]
|
||||
, button
|
||||
[ class "button is-light is-small"
|
||||
|
|
@ -1400,8 +1464,14 @@ viewUserDashboard model =
|
|||
[ button
|
||||
[ class "button is-primary is-large is-fullwidth"
|
||||
, onClick SaveTimeEntries
|
||||
, disabled (List.isEmpty model.selectedEntries)
|
||||
] [ text (if model.weekEditMode then "Änderungen speichern" else "Speichern") ]
|
||||
, disabled (List.isEmpty model.selectedEntries || model.isProcessing)
|
||||
]
|
||||
[ if model.isProcessing then
|
||||
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
|
||||
else
|
||||
text ""
|
||||
, text (if model.weekEditMode then "Änderungen speichern" else "Speichern")
|
||||
]
|
||||
]
|
||||
]
|
||||
else
|
||||
|
|
@ -1418,6 +1488,151 @@ viewUserDashboard model =
|
|||
]
|
||||
]
|
||||
|
||||
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" ]
|
||||
]
|
||||
, a
|
||||
[ class ("navbar-burger" ++ (if model.mobileMenuOpen then " is-active" else ""))
|
||||
, attribute "aria-label" "menu"
|
||||
, attribute "aria-expanded" (if model.mobileMenuOpen then "true" else "false")
|
||||
, onClick ToggleMobileMenu
|
||||
]
|
||||
[ span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
]
|
||||
]
|
||||
, div
|
||||
[ id "navbarAdmin"
|
||||
, class ("navbar-menu" ++ (if model.mobileMenuOpen then " is-active" else ""))
|
||||
]
|
||||
[ div [ class "navbar-end" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ span [ class "has-text-white mr-2" ] [ text model.username ]
|
||||
]
|
||||
, div [ class "navbar-item" ]
|
||||
[ button [ class "button is-light", onClick Logout ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-sign-out-alt" ] [] ]
|
||||
, span [] [ text "Abmelden" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, section [ class "section" ]
|
||||
[ div [ class "container" ]
|
||||
[ div [ class "tabs is-boxed" ]
|
||||
[ ul []
|
||||
[ 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" ] ]
|
||||
]
|
||||
]
|
||||
, case model.activeTab of
|
||||
ScheduleTab ->
|
||||
viewScheduleTab model
|
||||
UsersTab ->
|
||||
viewUsersTab model
|
||||
TimeEntriesTab ->
|
||||
viewTimeEntriesTab model
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg
|
||||
viewScheduleItemWithDay model dayOfWeek schedule =
|
||||
let
|
||||
isSelected = List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries
|
||||
|
||||
isClickable = (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing
|
||||
|
||||
boxClass =
|
||||
if isSelected then
|
||||
"box has-background-success-light"
|
||||
else if isClickable then
|
||||
"box has-background-white"
|
||||
else
|
||||
"box has-background-light"
|
||||
|
||||
typeText = if schedule.scheduleType == "break" then " (Pause)" else ""
|
||||
|
||||
cursorStyle = if isClickable then "pointer" else "not-allowed"
|
||||
opacity = if isClickable || isSelected then "1" else "0.6"
|
||||
in
|
||||
div
|
||||
[ class boxClass
|
||||
, onClick (if isClickable then ToggleScheduleSelection schedule.id dayOfWeek else FetchSchedules)
|
||||
, style "cursor" cursorStyle
|
||||
, style "margin-bottom" "0.5rem"
|
||||
, style "padding" "0.75rem"
|
||||
, style "opacity" opacity
|
||||
, style "transition" "all 0.2s ease"
|
||||
, style "border" (if isClickable && not isSelected then "2px solid transparent" else "2px solid currentColor")
|
||||
]
|
||||
[ p [ class "has-text-weight-bold is-size-7" ]
|
||||
[ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
|
||||
, p [ class "is-size-7" ]
|
||||
[ text (schedule.title ++ typeText) ]
|
||||
]
|
||||
|
||||
viewScheduleGridWithWeek : Model -> Html Msg
|
||||
viewScheduleGridWithWeek model =
|
||||
let
|
||||
days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]
|
||||
|
||||
groupedSchedules = List.range 0 4
|
||||
|> List.map (\day ->
|
||||
( day, List.filter (\s -> s.dayOfWeek == day) model.schedules )
|
||||
)
|
||||
in
|
||||
div []
|
||||
[
|
||||
div [ class "is-hidden-mobile" ]
|
||||
[ div [ class "table-container" ]
|
||||
[ table [ class "table is-bordered is-fullwidth" ]
|
||||
[ thead []
|
||||
[ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days)
|
||||
]
|
||||
, tbody []
|
||||
[ tr []
|
||||
(List.map (viewDayColumnWithWeek model) groupedSchedules)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "is-hidden-tablet" ]
|
||||
(List.map2 (viewDayMobile model) days groupedSchedules)
|
||||
]
|
||||
|
||||
viewDayMobile : Model -> String -> (Int, List Schedule) -> Html Msg
|
||||
viewDayMobile model dayName (dayOfWeek, schedules) =
|
||||
let
|
||||
dateForDay =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.dates
|
||||
|> List.filter (\(day, _) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|> Maybe.withDefault "N/A"
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
div [ class "box mb-4" ]
|
||||
[ p [ class "has-text-weight-bold has-text-centered mb-3" ]
|
||||
[ text (dayName ++ " - " ++ dateForDay) ]
|
||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
||||
]
|
||||
|
||||
viewUserWeeklySummary : Model -> Html Msg
|
||||
viewUserWeeklySummary model =
|
||||
case model.userWeeklySummary of
|
||||
|
|
@ -1462,46 +1677,6 @@ viewUserWeeklySummary model =
|
|||
[ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ]
|
||||
]
|
||||
|
||||
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" ]
|
||||
[ 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" ]
|
||||
[ div [ class "tabs is-boxed" ]
|
||||
[ ul []
|
||||
[ 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" ] ]
|
||||
]
|
||||
]
|
||||
, case model.activeTab of
|
||||
ScheduleTab ->
|
||||
viewScheduleTab model
|
||||
UsersTab ->
|
||||
viewUsersTab model
|
||||
TimeEntriesTab ->
|
||||
viewTimeEntriesTab model
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
viewScheduleTab : Model -> Html Msg
|
||||
viewScheduleTab model =
|
||||
|
|
@ -1534,7 +1709,6 @@ viewTimeEntriesTab model =
|
|||
viewTimeEntriesListWithEdit model
|
||||
]
|
||||
|
||||
-- Separate Edit Form View
|
||||
viewTimeEntriesEditForm : Model -> Html Msg
|
||||
viewTimeEntriesEditForm model =
|
||||
div [ class "box has-background-warning-light" ]
|
||||
|
|
@ -1649,7 +1823,6 @@ viewTimeEntryRowWithEdit model entry =
|
|||
isEditing = model.editingTimeEntryId == Just entry.id
|
||||
in
|
||||
if isEditing then
|
||||
-- Edit-Modus
|
||||
tr []
|
||||
[ td [] [ text entry.username ]
|
||||
, td []
|
||||
|
|
@ -1752,28 +1925,6 @@ viewWeekNavigation model =
|
|||
]
|
||||
]
|
||||
|
||||
viewScheduleGridWithWeek : Model -> Html Msg
|
||||
viewScheduleGridWithWeek model =
|
||||
let
|
||||
days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"]
|
||||
|
||||
groupedSchedules = List.range 0 4
|
||||
|> List.map (\day ->
|
||||
( 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 [ class "has-text-centered" ] [ text day ]) days)
|
||||
]
|
||||
, tbody []
|
||||
[ tr []
|
||||
(List.map (viewDayColumnWithWeek model) groupedSchedules)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
viewDayColumnWithWeek : Model -> (Int, List Schedule) -> Html Msg
|
||||
viewDayColumnWithWeek model (dayOfWeek, schedules) =
|
||||
let
|
||||
|
|
@ -1794,37 +1945,6 @@ viewDayColumnWithWeek model (dayOfWeek, schedules) =
|
|||
, 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
|
||||
|
||||
isClickable = not model.hasEntriesForCurrentWeek || model.weekEditMode
|
||||
|
||||
boxClass =
|
||||
if isSelected then
|
||||
"box has-background-success-light"
|
||||
else
|
||||
"box has-background-white"
|
||||
|
||||
typeText = if schedule.scheduleType == "break" then " (Pause)" else ""
|
||||
|
||||
cursorStyle = if isClickable then "pointer" else "not-allowed"
|
||||
opacity = if isClickable || isSelected then "1" else "0.6"
|
||||
in
|
||||
div
|
||||
[ class boxClass
|
||||
, onClick (if isClickable then ToggleScheduleSelection schedule.id dayOfWeek else CheckWeekHasEntries) -- Dummy-Event wenn nicht klickbar
|
||||
, style "cursor" cursorStyle
|
||||
, style "margin-bottom" "0.5rem"
|
||||
, style "padding" "0.75rem"
|
||||
, style "opacity" opacity
|
||||
]
|
||||
[ 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
|
||||
viewScheduleForm model =
|
||||
|
|
@ -1835,7 +1955,11 @@ viewScheduleForm model =
|
|||
[ label [ class "label" ] [ text "Wochentag" ]
|
||||
, div [ class "control" ]
|
||||
[ div [ class "select is-fullwidth" ]
|
||||
[ select [ onInput UpdateNewScheduleDay ]
|
||||
[ select
|
||||
[ onInput UpdateNewScheduleDay
|
||||
, disabled model.isProcessing
|
||||
, value model.newSchedule.dayOfWeek
|
||||
]
|
||||
[ option [ value "" ] [ text "Wochentag wählen" ]
|
||||
, option [ value "0" ] [ text "Montag" ]
|
||||
, option [ value "1" ] [ text "Dienstag" ]
|
||||
|
|
@ -1856,6 +1980,7 @@ viewScheduleForm model =
|
|||
, type_ "time"
|
||||
, value model.newSchedule.startTime
|
||||
, onInput UpdateNewScheduleStart
|
||||
, disabled model.isProcessing
|
||||
] []
|
||||
]
|
||||
]
|
||||
|
|
@ -1869,6 +1994,7 @@ viewScheduleForm model =
|
|||
, type_ "time"
|
||||
, value model.newSchedule.endTime
|
||||
, onInput UpdateNewScheduleEnd
|
||||
, disabled model.isProcessing
|
||||
] []
|
||||
]
|
||||
]
|
||||
|
|
@ -1880,7 +2006,11 @@ viewScheduleForm model =
|
|||
[ label [ class "label" ] [ text "Typ" ]
|
||||
, div [ class "control" ]
|
||||
[ div [ class "select is-fullwidth" ]
|
||||
[ select [ onInput UpdateNewScheduleType, value model.newSchedule.scheduleType ]
|
||||
[ select
|
||||
[ onInput UpdateNewScheduleType
|
||||
, value model.newSchedule.scheduleType
|
||||
, disabled model.isProcessing
|
||||
]
|
||||
[ option [ value "lesson" ] [ text "Unterricht" ]
|
||||
, option [ value "break" ] [ text "Pause" ]
|
||||
]
|
||||
|
|
@ -1898,6 +2028,7 @@ viewScheduleForm model =
|
|||
, placeholder "z.B. Mathematik"
|
||||
, value model.newSchedule.title
|
||||
, onInput UpdateNewScheduleTitle
|
||||
, disabled model.isProcessing
|
||||
] []
|
||||
]
|
||||
]
|
||||
|
|
@ -1905,9 +2036,23 @@ viewScheduleForm model =
|
|||
]
|
||||
, div [ class "field" ]
|
||||
[ div [ class "control" ]
|
||||
[ button [ class "button is-primary", onClick CreateSchedule ] [ text "Hinzufügen" ]
|
||||
[ button
|
||||
[ class "button is-primary"
|
||||
, onClick CreateSchedule
|
||||
, disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing)
|
||||
]
|
||||
[ if model.isProcessing then
|
||||
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
|
||||
else
|
||||
text ""
|
||||
, text " Hinzufügen"
|
||||
]
|
||||
]
|
||||
]
|
||||
, if String.isEmpty model.newSchedule.dayOfWeek then
|
||||
div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ]
|
||||
else
|
||||
text ""
|
||||
]
|
||||
|
||||
viewScheduleList : Model -> Html Msg
|
||||
|
|
@ -2035,7 +2180,6 @@ viewUserList model =
|
|||
viewUserRowWithActions : Model -> User -> Html Msg
|
||||
viewUserRowWithActions model user =
|
||||
if model.editingUserId == Just user.id then
|
||||
-- Edit Work Hours Mode
|
||||
tr []
|
||||
[ td [] [ text (String.fromInt user.id) ]
|
||||
, td [] [ text user.username ]
|
||||
|
|
@ -2207,7 +2351,7 @@ viewTimeEntriesList model =
|
|||
]
|
||||
]
|
||||
, tbody []
|
||||
(List.map (viewTimeEntryRowWithActions model) filteredEntries) -- KORRIGIERT: model übergeben
|
||||
(List.map (viewTimeEntryRowWithActions model) filteredEntries)
|
||||
]
|
||||
]
|
||||
|
||||
|
|
@ -2490,8 +2634,8 @@ weeklyHoursDecoder =
|
|||
(field "year" int)
|
||||
(field "week" int)
|
||||
(field "total_hours" float)
|
||||
(field "expected_hours" float) -- NEU
|
||||
(field "remaining_hours" float) -- NEU
|
||||
(field "expected_hours" float)
|
||||
(field "remaining_hours" float)
|
||||
|
||||
fetchWeekDates : String -> Int -> Int -> Cmd Msg
|
||||
fetchWeekDates token year week =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue