diff --git a/Dockerfile b/Dockerfile
index adae492..9d50907 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,51 +1,34 @@
-# Build stage for Elm frontend
-FROM node:25-alpine AS elm-build
+FROM node:22-alpine AS frontend-builder
-WORKDIR /frontend
+WORKDIR /src/frontend
-# Install Elm
-RUN npm install -g elm@latest-0.19.1
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm ci
-# Copy Elm files
-COPY frontend/elm.json .
-COPY frontend/src ./src
+COPY frontend/ ./
+RUN npm run build
-# Build Elm app
-RUN elm make src/Main.elm --optimize --output=elm.js
+FROM golang:1.25.5-alpine AS backend-builder
-# Build stage for Go backend
-FROM golang:1.25.3-alpine AS go-build
+WORKDIR /src/backend
-WORKDIR /app
-
-# Copy go mod files
COPY backend/go.mod backend/go.sum ./
RUN go mod download
-# Copy backend source
COPY backend/ ./
-# Build Go binary
-RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
+COPY --from=frontend-builder /src/frontend/dist ./dist
+
+RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o timetracker .
-# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
-WORKDIR /root/
+WORKDIR /app
-# Copy Go binary from build stage
-COPY --from=go-build /app/main .
+COPY --from=backend-builder /src/backend/timetracker .
-# Create static directory
-RUN mkdir -p /root/static
-
-# Copy Elm build artifacts
-COPY --from=elm-build /frontend/elm.js /root/static/
-COPY frontend/public/index.html /root/static/
-
-# Create volume for database
VOLUME ["/data"]
ENV PORT=8080
@@ -53,4 +36,4 @@ ENV DB_PATH=/data/timetracking.db
EXPOSE 8080
-CMD ["./main"]
+CMD ["./timetracker"]
diff --git a/README.md b/README.md
index 732cdbb..70397ee 100644
--- a/README.md
+++ b/README.md
@@ -65,8 +65,9 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
### Frontend
-- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI
-- **Bulma CSS**: Modernes CSS-Framework
+- **Svelte 5**: Reaktivität und Performance.
+- **Vite**: Build-Tooling.
+- **Tailwind CSS + DaisyUI**: UI-Komponenten.
- **Font Awesome**: Icons
- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung
@@ -93,9 +94,8 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
### Für lokale Entwicklung
-- Go 1.21+
-- Elm 0.19
-- Node.js 16+ (für Elm-Tooling)
+- Go 1.25+
+- Node.js 20+
- SQLite3
## 🚀 Installation
@@ -770,6 +770,6 @@ Todo
---
-**Version**: 1.5.0
-**Letztes Update**: November 2025
+**Version**: 1.7.0
+**Letztes Update**: Januar 2026
**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter
diff --git a/backend/database.go b/backend/database.go
index 66f3e54..42d1229 100644
--- a/backend/database.go
+++ b/backend/database.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"log"
+ "os"
"sort"
"strconv"
"strings"
@@ -14,20 +15,51 @@ import (
)
func InitDB(filepath string) *sql.DB {
- db, err := sql.Open("sqlite", filepath)
+ dsn := filepath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
+
+ db, err := sql.Open("sqlite", dsn)
if err != nil {
log.Fatal(err)
}
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+ db.SetConnMaxLifetime(time.Hour)
+
if err = db.Ping(); err != nil {
log.Fatal(err)
}
createTables(db)
createIndexes(db)
+
+ ensureAdminExists(db)
+
return db
}
+func ensureAdminExists(db *sql.DB) {
+ var count int
+ db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
+ if count == 0 {
+ var pw []byte
+ if os.Getenv("INITIAL_ADMIN_PASSWORD") == "" {
+ log.Println("Keine Benutzer gefunden. Erstelle Standard-Admin...")
+ pw, _ = bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
+ } else {
+ initialPassword := os.Getenv("INITIAL_ADMIN_PASSWORD")
+ pw, _ = bcrypt.GenerateFromPassword([]byte(initialPassword), bcrypt.DefaultCost)
+ }
+ _, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)",
+ "admin", string(pw), true, 0)
+ if err != nil {
+ log.Printf("Fehler beim Erstellen des Admins: %v", err)
+ } else {
+ log.Println("Admin erstellt. User: 'admin', Pass: 'admin123'")
+ }
+ }
+}
+
func createTables(db *sql.DB) {
queries := []string{
`CREATE TABLE IF NOT EXISTS users (
@@ -56,58 +88,47 @@ func createTables(db *sql.DB) {
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)
- )`,
- `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
+ FOREIGN KEY(user_id) REFERENCES users(id),
+ FOREIGN KEY(schedule_id) REFERENCES schedules(id)
)`,
`CREATE TABLE IF NOT EXISTS school_years (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- name TEXT NOT NULL UNIQUE,
- start_date DATE NOT NULL,
- end_date DATE NOT NULL,
- is_active BOOLEAN NOT NULL DEFAULT 0,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ start_date TEXT NOT NULL,
+ end_date TEXT NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`,
+ `CREATE TABLE IF NOT EXISTS substitutions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ date TEXT NOT NULL,
+ start_time TEXT NOT NULL,
+ end_time TEXT NOT NULL,
+ title TEXT NOT NULL,
+ notes TEXT,
+ taken_by_user_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(taken_by_user_id) REFERENCES users(id)
)`,
}
for _, query := range queries {
- if _, err := db.Exec(query); err != nil {
- log.Fatal(err)
+ _, err := db.Exec(query)
+ if err != nil {
+ log.Fatalf("Error creating table: %s\nQuery: %s", err, query)
}
}
-
- hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
- _, err := db.Exec(`
- INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
- VALUES (?, ?, ?, ?, ?)`,
- 1, "admin", string(hash), true, 40.0,
- )
- if err != nil {
- log.Fatal(err)
- }
}
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)`,
- `CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
- `CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)",
+ "CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)",
+ "CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
+ `CREATE INDEX IF NOT EXISTS idx_substitutions_date ON substitutions(date)`,
}
-
for _, idx := range indexes {
- if _, err := db.Exec(idx); err != nil {
- log.Printf("Warning: Failed to create index: %v", err)
- }
+ db.Exec(idx)
}
}
@@ -568,15 +589,16 @@ func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error {
}
func calculateHours(entry TimeEntry) float64 {
- if entry.Type == "lesson" {
+ switch entry.Type {
+ case "lesson":
return 1.0
- } else if entry.Type == "manual" {
+ case "manual":
hours, err := strconv.ParseFloat(entry.StartTime, 64)
if err != nil {
return 0
}
return hours
- } else {
+ default:
return calculateHoursDiff(entry.StartTime, entry.EndTime)
}
}
@@ -624,3 +646,107 @@ func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, w
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
return err
}
+
+func CreateSubstitution(db *sql.DB, date, start, end, title, notes string) error {
+ _, err := db.Exec(`
+ INSERT INTO substitutions (date, start_time, end_time, title, notes)
+ VALUES (?, ?, ?, ?, ?)
+ `, date, start, end, title, notes)
+ return err
+}
+
+func GetOpenSubstitutions(db *sql.DB) ([]Substitution, error) {
+ rows, err := db.Query(`
+ SELECT id, date, start_time, end_time, title, notes, created_at
+ FROM substitutions
+ WHERE taken_by_user_id IS NULL
+ AND date >= date('now')
+ ORDER BY date ASC, start_time ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []Substitution
+ for rows.Next() {
+ var s Substitution
+ if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &s.CreatedAt); err != nil {
+ continue
+ }
+ subs = append(subs, s)
+ }
+ return subs, nil
+}
+
+func AcceptSubstitution(db *sql.DB, substitutionID int, userID int) error {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var currentDate, start, end, title string
+ err = tx.QueryRow(`
+ SELECT date, start_time, end_time, title
+ FROM substitutions
+ WHERE id = ? AND taken_by_user_id IS NULL
+ `, substitutionID).Scan(¤tDate, &start, &end, &title)
+
+ if err == sql.ErrNoRows {
+ return fmt.Errorf("Vertretung wurde bereits vergeben oder existiert nicht")
+ }
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(`
+ UPDATE substitutions
+ SET taken_by_user_id = ?
+ WHERE id = ?
+ `, userID, substitutionID)
+ if err != nil {
+ return err
+ }
+
+ _, err = tx.Exec(`
+ INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time)
+ VALUES (?, 0, ?, 'lesson', ?, ?)
+ `, userID, currentDate, start, end)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+func GetAllSubstitutions(db *sql.DB) ([]Substitution, error) {
+ rows, err := db.Query(`
+ SELECT s.id, s.date, s.start_time, s.end_time, s.title, s.notes, s.taken_by_user_id, u.username
+ FROM substitutions s
+ LEFT JOIN users u ON s.taken_by_user_id = u.id
+ ORDER BY s.date DESC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var subs []Substitution
+ for rows.Next() {
+ var s Substitution
+ var takenID sql.NullInt64
+ var takenName sql.NullString
+
+ if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &takenID, &takenName); err != nil {
+ continue
+ }
+ if takenID.Valid {
+ id := int(takenID.Int64)
+ s.TakenByUserID = &id
+ s.TakenByUsername = takenName.String
+ }
+ subs = append(subs, s)
+ }
+ return subs, nil
+}
diff --git a/backend/go.mod b/backend/go.mod
index 2a1d344..76a4aed 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -3,7 +3,9 @@ module school-timetracker
go 1.25.3
require (
+ github.com/golang-jwt/jwt/v5 v5.3.0
github.com/jung-kurt/gofpdf v1.16.2
+ github.com/labstack/echo-jwt/v4 v4.3.1
github.com/labstack/echo/v4 v4.13.4
golang.org/x/crypto v0.43.0
golang.org/x/time v0.11.0
@@ -12,9 +14,7 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/labstack/echo-jwt/v4 v4.3.1 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/backend/handlers.go b/backend/handlers.go
index 06b3f57..31c2b97 100644
--- a/backend/handlers.go
+++ b/backend/handlers.go
@@ -3,8 +3,10 @@ package main
import (
"database/sql"
"fmt"
+ "io"
"log"
"net/http"
+ "os"
"strconv"
"strings"
"time"
@@ -69,6 +71,16 @@ func (app *App) LoginHandler(c echo.Context) error {
return HandleError(c, ErrInvalidCredentialsMsg())
}
+ if !user.IsAdmin {
+ _, err := VerifyLicenseFile()
+ if err != nil {
+ return c.JSON(http.StatusForbidden, map[string]string{
+ "error": "Lizenzfehler: " + err.Error(),
+ "code": "LICENSE_INVALID",
+ })
+ }
+
+ }
token, err := createToken(user.ID, user.Username, user.IsAdmin)
if err != nil {
return HandleError(c, ErrInternalMsg(err))
@@ -726,3 +738,177 @@ func (app *App) DeleteSchoolYearHandler(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
+
+func (app *App) ChangeMyPasswordHandler(c echo.Context) error {
+ claims, err := getClaims(c)
+ if err != nil {
+ return HandleError(c, ErrUnauthorizedMsg())
+ }
+
+ var req ChangePasswordRequest
+ if err := c.Bind(&req); err != nil {
+ return HandleError(c, ErrInvalidInputMsg("Anfragedaten"))
+ }
+
+ if len(req.NewPassword) < 6 {
+ return HandleError(c, ErrInvalidInputMsg("Neues Passwort muss mind. 6 Zeichen lang sein"))
+ }
+
+ var currentHash string
+ err = app.DB.QueryRow("SELECT password FROM users WHERE id = ?", claims.UserID).Scan(¤tHash)
+ if err != nil {
+ return HandleError(c, ErrDatabaseMsg(err))
+ }
+
+ if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPassword)); err != nil {
+ return HandleError(c, ErrInvalidInputMsg("Altes Passwort ist falsch"))
+ }
+
+ newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+
+ _, err = app.DB.Exec("UPDATE users SET password = ? WHERE id = ?", string(newHash), claims.UserID)
+ if err != nil {
+ return HandleError(c, ErrDatabaseMsg(err))
+ }
+
+ return c.JSON(http.StatusOK, map[string]string{"message": "Passwort erfolgreich geändert"})
+}
+
+func (app *App) GetLogoHandler(c echo.Context) error {
+ if _, err := os.Stat("school_logo.png"); os.IsNotExist(err) {
+ return c.NoContent(http.StatusNotFound)
+ }
+ c.Response().Header().Set("Cache-Control", "no-cache")
+ return c.File("school_logo.png")
+}
+
+func (app *App) UploadLogoHandler(c echo.Context) error {
+ file, err := c.FormFile("logo")
+ if err != nil {
+ return HandleError(c, ErrInvalidInputMsg("Keine Datei hochgeladen"))
+ }
+
+ src, err := file.Open()
+ if err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+ defer src.Close()
+
+ dst, err := os.Create("school_logo.png")
+ if err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+ defer dst.Close()
+
+ if _, err = io.Copy(dst, src); err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+
+ return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"})
+}
+
+func (app *App) GetLicenseStatusHandler(c echo.Context) error {
+ var count int
+ app.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
+
+ status := GetCurrentLicenseStatus(nil)
+ status.UserCount = count
+
+ if status.IsValid && status.MaxUsers > 0 && count > status.MaxUsers {
+ status.IsValid = false
+ status.Message = fmt.Sprintf("Benutzerlimit überschritten (%d / %d)", count, status.MaxUsers)
+ }
+
+ return c.JSON(http.StatusOK, status)
+}
+
+func (app *App) UploadLicenseHandler(c echo.Context) error {
+ file, err := c.FormFile("license")
+ if err != nil {
+ return HandleError(c, ErrInvalidInputMsg("Keine Datei"))
+ }
+
+ src, err := file.Open()
+ if err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+ defer src.Close()
+
+ dst, err := os.Create("license.lic")
+ if err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+ defer dst.Close()
+
+ if _, err = io.Copy(dst, src); err != nil {
+ return HandleError(c, ErrInternalMsg(err))
+ }
+
+ if _, err := VerifyLicenseFile(); err != nil {
+ return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz hochgeladen, aber ungültig: " + err.Error()})
+ }
+
+ return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz erfolgreich aktiviert"})
+}
+
+func (app *App) CreateSubstitutionHandler(c echo.Context) error {
+ var req CreateSubstitutionRequest
+ if err := c.Bind(&req); err != nil {
+ return HandleError(c, ErrInvalidInputMsg("Eingabedaten"))
+ }
+ if req.Date == "" || req.StartTime == "" || req.Title == "" {
+ return HandleError(c, ErrMissingFieldMsg("Pflichtfelder"))
+ }
+
+ if err := CreateSubstitution(app.DB, req.Date, req.StartTime, req.EndTime, req.Title, req.Notes); err != nil {
+ return HandleError(c, ErrDatabaseMsg(err))
+ }
+
+ return c.JSON(http.StatusCreated, map[string]string{"message": "Vertretung ausgeschrieben"})
+}
+
+func (app *App) GetAllSubstitutionsHandler(c echo.Context) error {
+ subs, err := GetAllSubstitutions(app.DB)
+ if err != nil {
+ return HandleError(c, ErrDatabaseMsg(err))
+ }
+ if subs == nil {
+ subs = []Substitution{}
+ }
+ return c.JSON(http.StatusOK, subs)
+}
+
+func (app *App) GetOpenSubstitutionsHandler(c echo.Context) error {
+ subs, err := GetOpenSubstitutions(app.DB)
+ if err != nil {
+ return HandleError(c, ErrDatabaseMsg(err))
+ }
+ if subs == nil {
+ subs = []Substitution{}
+ }
+ return c.JSON(http.StatusOK, subs)
+}
+
+func (app *App) AcceptSubstitutionHandler(c echo.Context) error {
+ claims, err := getClaims(c)
+ if err != nil {
+ return HandleError(c, ErrUnauthorizedMsg())
+ }
+
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ return HandleError(c, ErrInvalidInputMsg("ID"))
+ }
+
+ if err := AcceptSubstitution(app.DB, id, claims.UserID); err != nil {
+ if err.Error() == "Vertretung wurde bereits vergeben oder existiert nicht" {
+ return HandleError(c, ErrAlreadyExistsMsg("Diese Vertretung ist leider schon vergeben"))
+ }
+ return HandleError(c, ErrDatabaseMsg(err))
+ }
+
+ return c.JSON(http.StatusOK, map[string]string{"message": "Vertretung erfolgreich übernommen!"})
+}
diff --git a/backend/license.go b/backend/license.go
new file mode 100644
index 0000000..9882f25
--- /dev/null
+++ b/backend/license.go
@@ -0,0 +1,72 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "time"
+)
+
+const PublicKeyHex = "ab8287380d4f26b66b3e8067e179a2e304dcb3c4070963edd213cca9b225978f"
+
+func VerifyLicenseFile() (*LicenseData, error) {
+ bytes, err := os.ReadFile("license.lic")
+ if err != nil {
+ return nil, fmt.Errorf("Keine Lizenzdatei gefunden")
+ }
+
+ var lic LicenseFile
+ if err := json.Unmarshal(bytes, &lic); err != nil {
+ return nil, fmt.Errorf("Lizenzdatei beschädigt")
+ }
+
+ pubBytes, _ := hex.DecodeString(PublicKeyHex)
+ publicKey := ed25519.PublicKey(pubBytes)
+
+ dataBytes, _ := json.Marshal(lic.Data)
+ sigBytes, err := base64.StdEncoding.DecodeString(lic.Signature)
+ if err != nil {
+ return nil, fmt.Errorf("Signatur ungültig")
+ }
+
+ if !ed25519.Verify(publicKey, dataBytes, sigBytes) {
+ return nil, fmt.Errorf("Lizenz-Signatur ungültig (Manipuliert?)")
+ }
+
+ expiry, err := time.Parse("2006-01-02", lic.Data.ExpiresAt)
+ if err != nil {
+ return nil, fmt.Errorf("Ungültiges Datumsformat")
+ }
+ if time.Now().After(expiry) {
+ return &lic.Data, fmt.Errorf("Lizenz abgelaufen am %s", lic.Data.ExpiresAt)
+ }
+
+ return &lic.Data, nil
+}
+
+func GetCurrentLicenseStatus(db *any) LicenseStatus {
+ lic, err := VerifyLicenseFile()
+ status := LicenseStatus{
+ IsValid: err == nil,
+ Message: "Gültig",
+ }
+
+ if err != nil {
+ status.Message = err.Error()
+ if lic != nil {
+ status.SchoolName = lic.SchoolName
+ status.ExpiresAt = lic.ExpiresAt
+ status.MaxUsers = lic.MaxUsers
+ }
+ return status
+ }
+
+ status.SchoolName = lic.SchoolName
+ status.ExpiresAt = lic.ExpiresAt
+ status.MaxUsers = lic.MaxUsers
+
+ return status
+}
diff --git a/backend/load-env.sh b/backend/load-env.sh
index 7358e39..d374b7a 100755
--- a/backend/load-env.sh
+++ b/backend/load-env.sh
@@ -11,7 +11,7 @@ else
fi
if [ -z "$PORT" ]; then
- export PORT=8080
+ export PORT=8085
fi
if [ -z "$DB_PATH" ]; then
diff --git a/backend/main.go b/backend/main.go
index 84cb7f1..0cc162e 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -1,6 +1,10 @@
package main
import (
+ "embed"
+ "fmt"
+ "io"
+ "io/fs"
"log"
"net/http"
"os"
@@ -10,6 +14,9 @@ import (
"github.com/labstack/echo/v4/middleware"
)
+//go:embed dist
+var frontendDist embed.FS
+
func main() {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
@@ -26,14 +33,15 @@ func main() {
e.Use(middleware.Logger())
e.Use(middleware.Recover())
- // CORS Configuration
- allowOrigins := []string{"*"} // Default for development
+ e.Use(middleware.Gzip())
+
+ e.Use(middleware.Secure())
+
+ allowOrigins := []string{"*"}
if os.Getenv("ENVIRONMENT") == "production" {
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
if origins != "" {
allowOrigins = strings.Split(origins, ",")
- } else {
- log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.")
}
}
@@ -46,6 +54,7 @@ func main() {
e.HTTPErrorHandler = customHTTPErrorHandler
e.POST("/api/login", app.LoginHandler)
+ e.GET("/api/logo", app.GetLogoHandler)
protected := e.Group("/api")
protected.Use(JWTMiddleware())
@@ -59,7 +68,10 @@ func main() {
protected.GET("/week-has-entries", app.CheckWeekHasEntries)
protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
protected.GET("/my-info", app.GetMyInfoHandler)
+ protected.POST("/change-password", app.ChangeMyPasswordHandler)
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
+ protected.GET("/substitutions/open", app.GetOpenSubstitutionsHandler)
+ protected.POST("/substitutions/:id/accept", app.AcceptSubstitutionHandler)
}
admin := e.Group("/api/admin")
@@ -83,13 +95,42 @@ func main() {
admin.DELETE("/school-years/:id", app.DeleteSchoolYearHandler)
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
+ admin.POST("/settings/logo", app.UploadLogoHandler)
+ admin.GET("/settings/license", app.GetLicenseStatusHandler)
+ admin.POST("/settings/license", app.UploadLicenseHandler)
+ admin.GET("/substitutions", app.GetAllSubstitutionsHandler)
+ admin.POST("/substitutions", app.CreateSubstitutionHandler)
}
- e.Static("/", "./static")
+ distDir, err := fs.Sub(frontendDist, "dist")
+ if err != nil {
+ log.Fatal("Fehler beim Laden des eingebetteten Frontends:", err)
+ }
+
+ fileHandler := http.FileServer(http.FS(distDir))
+ e.GET("/*", func(c echo.Context) error {
+ path := c.Request().URL.Path
+ f, err := distDir.Open(strings.TrimPrefix(path, "/"))
+ if err == nil {
+ f.Close()
+ fileHandler.ServeHTTP(c.Response(), c.Request())
+ return nil
+ }
+
+ index, err := distDir.Open("index.html")
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Frontend index.html missing")
+ }
+ defer index.Close()
+
+ stat, _ := index.Stat()
+ http.ServeContent(c.Response(), c.Request(), "index.html", stat.ModTime(), index.(io.ReadSeeker))
+ return nil
+ })
port := os.Getenv("PORT")
if port == "" {
- port = "8080"
+ port = "8085"
}
log.Printf("Server starting on port %s", port)
@@ -102,16 +143,9 @@ func customHTTPErrorHandler(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
- message = he.Message.(string)
+ message = fmt.Sprintf("%v", he.Message)
}
- if !c.Response().Committed {
- if c.Request().Method == http.MethodHead {
- c.NoContent(code)
- } else {
- c.JSON(code, map[string]string{
- "error": message,
- })
- }
- }
+ c.Logger().Error(err)
+ c.JSON(code, map[string]string{"message": message})
}
diff --git a/backend/models.go b/backend/models.go
index 8429bb6..4e2f2e8 100644
--- a/backend/models.go
+++ b/backend/models.go
@@ -23,10 +23,10 @@ type WeeklyHours struct {
Week int `json:"week"`
Year int `json:"year"`
TotalHours float64 `json:"total_hours"`
- YearlyTarget float64 `json:"yearly_target"` // NEU
- YearlyActual float64 `json:"yearly_actual"` // NEU
- WeeklyTarget float64 `json:"weekly_target"` // NEU
- RemainingYearly float64 `json:"remaining_yearly"` // NEU
+ YearlyTarget float64 `json:"yearly_target"`
+ YearlyActual float64 `json:"yearly_actual"`
+ WeeklyTarget float64 `json:"weekly_target"`
+ RemainingYearly float64 `json:"remaining_yearly"`
}
type User struct {
@@ -101,3 +101,48 @@ type Claims struct {
IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
}
+
+type ChangePasswordRequest struct {
+ OldPassword string `json:"old_password"`
+ NewPassword string `json:"new_password"`
+}
+
+type LicenseData struct {
+ SchoolName string `json:"school_name"`
+ MaxUsers int `json:"max_users"`
+ ExpiresAt string `json:"expires_at"`
+}
+
+type LicenseFile struct {
+ Data LicenseData `json:"data"`
+ Signature string `json:"signature"`
+}
+
+type LicenseStatus struct {
+ IsValid bool `json:"is_valid"`
+ SchoolName string `json:"school_name"`
+ ExpiresAt string `json:"expires_at"`
+ MaxUsers int `json:"max_users"`
+ UserCount int `json:"user_count"`
+ Message string `json:"message"`
+}
+
+type Substitution struct {
+ ID int `json:"id"`
+ Date string `json:"date"`
+ StartTime string `json:"start_time"`
+ EndTime string `json:"end_time"`
+ Title string `json:"title"`
+ Notes string `json:"notes"`
+ TakenByUserID *int `json:"taken_by_user_id,omitempty"`
+ TakenByUsername string `json:"taken_by_username,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type CreateSubstitutionRequest struct {
+ Date string `json:"date" validate:"required"`
+ StartTime string `json:"start_time" validate:"required"`
+ EndTime string `json:"end_time" validate:"required"`
+ Title string `json:"title" validate:"required"`
+ Notes string `json:"notes"`
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 221d016..39e31ce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,23 +2,18 @@ services:
timetracking:
build: .
container_name: school-timetracking
+ restart: unless-stopped
ports:
- "8080:8080"
environment:
- PORT=8080
+ - ENVIRONMENT=production
- DB_PATH=/data/timetracking.db
- - JWT_SECRET=your-default-secret-change-me
- - TZ=Europe/Berlin # Optional: Zeitzone
+ - JWT_SECRET=change-me-to-something-secure-and-long
+ - TZ=Europe/Berlin
+ - CORS_ALLOWED_ORIGINS=http://localhost:8080
volumes:
- - timetracking-data:/data
- restart: unless-stopped
- networks:
- - timetracking-net
+ - timetracking_data:/data
volumes:
- timetracking-data:
- driver: local
-
-networks:
- timetracking-net:
- driver: bridge
+ timetracking_data:
diff --git a/frontend/elm.json b/frontend/elm.json
deleted file mode 100644
index 07196ee..0000000
--- a/frontend/elm.json
+++ /dev/null
@@ -1,27 +0,0 @@
-{
- "type": "application",
- "source-directories": [
- "src"
- ],
- "elm-version": "0.19.1",
- "dependencies": {
- "direct": {
- "elm/browser": "1.0.2",
- "elm/bytes": "1.0.8",
- "elm/core": "1.0.5",
- "elm/file": "1.0.5",
- "elm/html": "1.0.0",
- "elm/http": "2.0.0",
- "elm/json": "1.1.3",
- "elm/time": "1.0.0"
- },
- "indirect": {
- "elm/url": "1.0.0",
- "elm/virtual-dom": "1.0.3"
- }
- },
- "test-dependencies": {
- "direct": {},
- "indirect": {}
- }
-}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..55d0a6d
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+ school-timetracker
+
+
+
+
+
+
+
+
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
new file mode 100644
index 0000000..49869a6
--- /dev/null
+++ b/frontend/jsconfig.json
@@ -0,0 +1,39 @@
+{
+ "compilerOptions": {
+ "moduleResolution": "bundler",
+ "target": "ESNext",
+ "module": "ESNext",
+ /**
+ * svelte-preprocess cannot figure out whether you have
+ * a value or a type, so tell TypeScript to enforce using
+ * `import type` instead of `import` for Types.
+ */
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "resolveJsonModule": true,
+ /**
+ * To have warnings / errors of the Svelte compiler at the
+ * correct position, enable source maps by default.
+ */
+ "sourceMap": true,
+ "esModuleInterop": true,
+ "types": [
+ "vite/client"
+ ],
+ "skipLibCheck": true,
+ /**
+ * Typecheck JS in `.svelte` and `.js` files by default.
+ * Disable this if you'd like to use dynamic types.
+ */
+ "checkJs": true
+ },
+ /**
+ * Use global.d.ts instead of compilerOptions.types
+ * to avoid limiting type declarations.
+ */
+ "include": [
+ "src/**/*.d.ts",
+ "src/**/*.js",
+ "src/**/*.svelte"
+ ]
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..81e876f
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,2061 @@
+{
+ "name": "school-timetracker",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "school-timetracker",
+ "version": "0.0.0",
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "daisyui": "^5.5.14",
+ "tailwindcss": "^4.1.18"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "autoprefixer": "^10.4.23",
+ "postcss": "^8.5.6",
+ "svelte": "^5.43.8",
+ "vite": "^7.2.4"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
+ "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
+ "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
+ "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
+ "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
+ "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
+ "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
+ "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
+ "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
+ "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
+ "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
+ "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
+ "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
+ "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
+ "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
+ "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
+ "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
+ "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
+ "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
+ "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
+ "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
+ "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
+ "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
+ "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+ "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "tailwindcss": "4.1.18"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.23",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
+ "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001760",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.9.14",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
+ "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001764",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
+ "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/daisyui": {
+ "version": "5.5.14",
+ "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz",
+ "integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/saadeghi/daisyui?sponsor=1"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz",
+ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.267",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
+ "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz",
+ "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.55.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
+ "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.55.1",
+ "@rollup/rollup-android-arm64": "4.55.1",
+ "@rollup/rollup-darwin-arm64": "4.55.1",
+ "@rollup/rollup-darwin-x64": "4.55.1",
+ "@rollup/rollup-freebsd-arm64": "4.55.1",
+ "@rollup/rollup-freebsd-x64": "4.55.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.55.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.55.1",
+ "@rollup/rollup-linux-arm64-musl": "4.55.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.55.1",
+ "@rollup/rollup-linux-loong64-musl": "4.55.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.55.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.55.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.55.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.55.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-gnu": "4.55.1",
+ "@rollup/rollup-linux-x64-musl": "4.55.1",
+ "@rollup/rollup-openbsd-x64": "4.55.1",
+ "@rollup/rollup-openharmony-arm64": "4.55.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.55.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.55.1",
+ "@rollup/rollup-win32-x64-gnu": "4.55.1",
+ "@rollup/rollup-win32-x64-msvc": "4.55.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.46.3",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.3.tgz",
+ "integrity": "sha512-Y5juST3x+/ySty5tYJCVWa6Corkxpt25bUZQHqOceg9xfMUtDsFx6rCsG6cYf1cA6vzDi66HIvaki0byZZX95A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.5.0",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.1",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..fe2de5a
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "school-timetracker",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "autoprefixer": "^10.4.23",
+ "postcss": "^8.5.6",
+ "svelte": "^5.43.8",
+ "vite": "^7.2.4"
+ },
+ "dependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "daisyui": "^5.5.14",
+ "tailwindcss": "^4.1.18"
+ }
+}
diff --git a/frontend/public/index.html b/frontend/public/index.html
deleted file mode 100644
index 12ae1c0..0000000
--- a/frontend/public/index.html
+++ /dev/null
@@ -1,338 +0,0 @@
-
-
-
-
-
-
-
- Zeiterfassung
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/frontend/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm
deleted file mode 100644
index 0de5c4e..0000000
--- a/frontend/src/Api/Auth.elm
+++ /dev/null
@@ -1,21 +0,0 @@
-module Api.Auth exposing (loginRequest)
-
-import Api.Decoders exposing (loginDecoder)
-import Http
-import Json.Encode as Encode
-import Types.Api exposing (LoginResult)
-import Types.Msg exposing (Msg(..))
-
-
-loginRequest : String -> String -> Cmd Msg
-loginRequest username password =
- Http.post
- { url = "/api/login"
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "username", Encode.string username )
- , ( "password", Encode.string password )
- ]
- , expect = Http.expectJson LoginResponse loginDecoder
- }
diff --git a/frontend/src/Api/Decoders.elm b/frontend/src/Api/Decoders.elm
deleted file mode 100644
index cb72efa..0000000
--- a/frontend/src/Api/Decoders.elm
+++ /dev/null
@@ -1,109 +0,0 @@
-module Api.Decoders exposing
- ( apiErrorDecoder
- , loginDecoder
- , scheduleDecoder
- , schoolYearDecoder
- , timeEntryDecoder
- , userDecoder
- , weekDatesDecoder
- , weeklyHoursDecoder
- , yearlyHoursSummaryDecoder
- )
-
-import Dict
-import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string)
-import Types.Api exposing (ApiError, LoginResult)
-import Types.Model exposing (..)
-
-
-loginDecoder : Decoder LoginResult
-loginDecoder =
- Decode.map3 LoginResult
- (field "token" string)
- (field "username" string)
- (field "is_admin" bool)
-
-
-scheduleDecoder : Decoder Schedule
-scheduleDecoder =
- Decode.map6 Schedule
- (field "id" int)
- (field "day_of_week" int)
- (field "start_time" string)
- (field "end_time" string)
- (field "type" string)
- (field "title" string)
-
-
-timeEntryDecoder : Decoder TimeEntry
-timeEntryDecoder =
- 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)
-
-
-userDecoder : Decoder User
-userDecoder =
- Decode.map4 User
- (field "id" int)
- (field "username" string)
- (field "is_admin" bool)
- (field "yearly_hours" float)
-
-
-weekDatesDecoder : Decoder WeekDates
-weekDatesDecoder =
- Decode.map4 WeekDates
- (field "year" int)
- (field "week" int)
- (field "dates" (Decode.dict string) |> Decode.map Dict.toList)
- (field "range" string)
-
-
-weeklyHoursDecoder : Decoder WeeklyHours
-weeklyHoursDecoder =
- Decode.map7 WeeklyHours
- (field "user_id" int)
- (field "username" string)
- (field "year" int)
- (field "week" int)
- (field "total_hours" float)
- (field "expected_hours" float)
- (field "remaining_hours" float)
-
-
-yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary
-yearlyHoursSummaryDecoder =
- Decode.succeed YearlyHoursSummary
- |> Decode.andThen (\f -> Decode.map f (field "user_id" int))
- |> Decode.andThen (\f -> Decode.map f (field "username" string))
- |> Decode.andThen (\f -> Decode.map f (field "year" int))
- |> Decode.andThen (\f -> Decode.map f (field "week" int))
- |> Decode.andThen (\f -> Decode.map f (field "total_hours" float))
- |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float))
- |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float))
- |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float))
- |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float))
-
-
-schoolYearDecoder : Decoder SchoolYear
-schoolYearDecoder =
- Decode.map5 SchoolYear
- (field "id" int)
- (field "name" string)
- (field "start_date" string)
- (field "end_date" string)
- (field "is_active" bool)
-
-
-apiErrorDecoder : Decoder ApiError
-apiErrorDecoder =
- Decode.map2 ApiError
- (field "code" string)
- (field "message" string)
diff --git a/frontend/src/Api/Schedule.elm b/frontend/src/Api/Schedule.elm
deleted file mode 100644
index f966645..0000000
--- a/frontend/src/Api/Schedule.elm
+++ /dev/null
@@ -1,120 +0,0 @@
-module Api.Schedule exposing
- ( createSchedule
- , deleteSchedule
- , fetchSchedules
- , saveTimeEntriesForWeek
- )
-
-import Api.Decoders exposing (scheduleDecoder)
-import Http
-import Json.Decode
-import Json.Encode as Encode
-import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates)
-import Types.Msg exposing (Msg(..))
-
-
-fetchSchedules : Maybe String -> Cmd Msg
-fetchSchedules maybeToken =
- case maybeToken of
- Just token ->
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/schedules"
- , body = Http.emptyBody
- , expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
-
- Nothing ->
- Cmd.none
-
-
-createSchedule : String -> NewSchedule -> Cmd Msg
-createSchedule token schedule =
- case String.toInt schedule.dayOfWeek of
- Just day ->
- Http.request
- { method = "POST"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/schedules"
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "day_of_week", Encode.int day )
- , ( "start_time", Encode.string schedule.startTime )
- , ( "end_time", Encode.string schedule.endTime )
- , ( "type", Encode.string schedule.scheduleType )
- , ( "title", Encode.string schedule.title )
- ]
- , expect = Http.expectWhatever ScheduleCreated
- , timeout = Nothing
- , tracker = Nothing
- }
-
- Nothing ->
- Cmd.none
-
-
-deleteSchedule : String -> Int -> Cmd Msg
-deleteSchedule token scheduleId =
- Http.request
- { method = "DELETE"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId
- , body = Http.emptyBody
- , expect = Http.expectWhatever ScheduleDeleted
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg
-saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates =
- case maybeWeekDates of
- Nothing ->
- Cmd.none
-
- Just weekDates ->
- let
- getScheduleById id =
- List.filter (\s -> s.id == id) schedules |> List.head
-
- getDateForDay dayOfWeek =
- weekDates.dates
- |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
- |> List.head
- |> Maybe.map Tuple.second
-
- createEntryData entry =
- case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of
- ( Just schedule, Just dateStr ) ->
- Just <|
- 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 )
- ]
-
- _ ->
- Nothing
-
- entriesData =
- List.filterMap createEntryData selectedEntries
- in
- if List.isEmpty entriesData then
- Cmd.none
-
- else
- Http.request
- { method = "POST"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/time-entries/batch"
- , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ]
- , expect = Http.expectWhatever TimeEntriesSaved
- , timeout = Nothing
- , tracker = Nothing
- }
diff --git a/frontend/src/Api/SchoolYear.elm b/frontend/src/Api/SchoolYear.elm
deleted file mode 100644
index be1fb63..0000000
--- a/frontend/src/Api/SchoolYear.elm
+++ /dev/null
@@ -1,85 +0,0 @@
-module Api.SchoolYear exposing
- ( activateSchoolYear
- , createSchoolYear
- , deleteSchoolYear
- , fetchActiveSchoolYear
- , fetchSchoolYears
- )
-
-import Api.Decoders exposing (schoolYearDecoder)
-import Http
-import Json.Decode as Decode
-import Json.Encode as Encode
-import Types.Model exposing (NewSchoolYear)
-import Types.Msg exposing (Msg(..))
-
-
-fetchSchoolYears : String -> Cmd Msg
-fetchSchoolYears token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/school-years"
- , body = Http.emptyBody
- , expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-fetchActiveSchoolYear : String -> Cmd Msg
-fetchActiveSchoolYear token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/school-year/active"
- , body = Http.emptyBody
- , expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-createSchoolYear : String -> NewSchoolYear -> Cmd Msg
-createSchoolYear token schoolYear =
- Http.request
- { method = "POST"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/school-years"
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "name", Encode.string schoolYear.name )
- , ( "start_date", Encode.string schoolYear.startDate )
- , ( "end_date", Encode.string schoolYear.endDate )
- ]
- , expect = Http.expectWhatever SchoolYearCreated
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-activateSchoolYear : String -> Int -> Cmd Msg
-activateSchoolYear token id =
- Http.request
- { method = "PUT"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate"
- , body = Http.emptyBody
- , expect = Http.expectWhatever SchoolYearActivated
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-deleteSchoolYear : String -> Int -> Cmd Msg
-deleteSchoolYear token id =
- Http.request
- { method = "DELETE"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/school-years/" ++ String.fromInt id
- , body = Http.emptyBody
- , expect = Http.expectWhatever SchoolYearDeleted
- , timeout = Nothing
- , tracker = Nothing
- }
diff --git a/frontend/src/Api/TimeEntry.elm b/frontend/src/Api/TimeEntry.elm
deleted file mode 100644
index c1ebede..0000000
--- a/frontend/src/Api/TimeEntry.elm
+++ /dev/null
@@ -1,201 +0,0 @@
-module Api.TimeEntry exposing
- ( checkWeekHasEntries
- , createAdminTimeEntry
- , deleteTimeEntry
- , deleteWeekEntries
- , downloadYearlySummaryPDF
- , fetchAllTimeEntries
- , fetchMyTimeEntries
- , fetchWeekDates
- , fetchWeeklyHours
- , fetchYearlyHoursSummary
- , updateTimeEntry
- )
-
-import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder)
-import Bytes exposing (Bytes)
-import Http
-import Json.Decode as Decode exposing (bool, field)
-import Json.Encode as Encode
-import Types.Model exposing (AdminManualEntry, EditingTimeEntry)
-import Types.Msg exposing (Msg(..))
-
-
-fetchMyTimeEntries : String -> Cmd Msg
-fetchMyTimeEntries token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/my-time-entries"
- , body = Http.emptyBody
- , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-fetchAllTimeEntries : String -> Cmd Msg
-fetchAllTimeEntries token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/time-entries"
- , body = Http.emptyBody
- , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-fetchWeekDates : String -> Int -> Int -> Cmd Msg
-fetchWeekDates token year week =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
- , body = Http.emptyBody
- , expect = Http.expectJson WeekDatesReceived weekDatesDecoder
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-checkWeekHasEntries : String -> Int -> Int -> Cmd Msg
-checkWeekHasEntries token year week =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
- , body = Http.emptyBody
- , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool)
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-deleteWeekEntries : String -> Int -> Int -> Cmd Msg
-deleteWeekEntries token year week =
- Http.request
- { method = "DELETE"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
- , body = Http.emptyBody
- , expect = Http.expectWhatever WeekEntriesDeleted
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg
-updateTimeEntry token entry =
- Http.request
- { method = "PUT"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "date", Encode.string entry.date )
- , ( "start_time", Encode.string entry.startTime )
- , ( "end_time", Encode.string entry.endTime )
- , ( "type", Encode.string entry.entryType )
- ]
- , expect = Http.expectWhatever TimeEntrySaved
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-deleteTimeEntry : String -> Int -> Cmd Msg
-deleteTimeEntry token entryId =
- Http.request
- { method = "DELETE"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/time-entries/" ++ String.fromInt entryId
- , body = Http.emptyBody
- , expect = Http.expectWhatever TimeEntryDeleted
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg
-createAdminTimeEntry token entry =
- case entry.selectedUserId of
- Just userId ->
- Http.request
- { method = "POST"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/time-entry"
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "user_id", Encode.int userId )
- , ( "date", Encode.string entry.date )
- , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) )
- , ( "type", Encode.string "manual" )
- ]
- , expect = Http.expectWhatever AdminTimeEntrySaved
- , timeout = Nothing
- , tracker = Nothing
- }
-
- Nothing ->
- Cmd.none
-
-
-fetchYearlyHoursSummary : String -> Cmd Msg
-fetchYearlyHoursSummary token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/yearly-hours-summary"
- , body = Http.emptyBody
- , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-downloadYearlySummaryPDF : String -> Cmd Msg
-downloadYearlySummaryPDF token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/yearly-summary/pdf"
- , body = Http.emptyBody
- , expect =
- Http.expectBytesResponse YearlySummaryPDFReceived
- (\response ->
- case response of
- Http.GoodStatus_ _ body ->
- Ok body
-
- Http.BadUrl_ url ->
- Err (Http.BadUrl url)
-
- Http.Timeout_ ->
- Err Http.Timeout
-
- Http.NetworkError_ ->
- Err Http.NetworkError
-
- Http.BadStatus_ metadata _ ->
- Err (Http.BadStatus metadata.statusCode)
- )
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-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 Api.Decoders.weeklyHoursDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
diff --git a/frontend/src/Api/User.elm b/frontend/src/Api/User.elm
deleted file mode 100644
index 17c77ac..0000000
--- a/frontend/src/Api/User.elm
+++ /dev/null
@@ -1,110 +0,0 @@
-module Api.User exposing
- ( createUser
- , deleteUser
- , fetchMyInfo
- , fetchUsers
- , resetUserPassword
- , updateUserWorkHours
- )
-
-import Api.Decoders exposing (userDecoder)
-import Http
-import Json.Decode as Decode
-import Json.Encode as Encode
-import Types.Model exposing (NewUser)
-import Types.Msg exposing (Msg(..))
-
-
-fetchUsers : String -> Cmd Msg
-fetchUsers token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/users/list"
- , body = Http.emptyBody
- , expect = Http.expectJson UsersReceived (Decode.list userDecoder)
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-fetchMyInfo : String -> Cmd Msg
-fetchMyInfo token =
- Http.request
- { method = "GET"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/my-info"
- , body = Http.emptyBody
- , expect = Http.expectJson MyInfoReceived userDecoder
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-createUser : String -> NewUser -> Cmd Msg
-createUser token user =
- Http.request
- { method = "POST"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/users"
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "username", Encode.string user.username )
- , ( "password", Encode.string user.password )
- , ( "is_admin", Encode.bool user.isAdmin )
- ]
- , expect = Http.expectWhatever UserCreated
- , timeout = Nothing
- , tracker = Nothing
- }
-
-
-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
- }
-
-
-updateUserWorkHours : String -> Int -> String -> Cmd Msg
-updateUserWorkHours token userId hours =
- case String.toFloat hours of
- Just workHours ->
- Http.request
- { method = "PUT"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/users/" ++ String.fromInt userId
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "yearly_hours", Encode.float workHours ) ]
- , expect = Http.expectWhatever UserWorkHoursSaved
- , timeout = Nothing
- , tracker = Nothing
- }
-
- Nothing ->
- Cmd.none
-
-
-resetUserPassword : String -> Int -> String -> Cmd Msg
-resetUserPassword token userId newPassword =
- Http.request
- { method = "PUT"
- , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
- , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password"
- , body =
- Http.jsonBody <|
- Encode.object
- [ ( "new_password", Encode.string newPassword ) ]
- , expect = Http.expectWhatever ResetPasswordSaved
- , timeout = Nothing
- , tracker = Nothing
- }
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 0000000..f7bcbde
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+ {#if !isAuthenticated}
+
+ {:else if user?.isAdmin}
+
+ {:else}
+
+ {/if}
+
+
diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm
deleted file mode 100644
index 6f29eab..0000000
--- a/frontend/src/Main.elm
+++ /dev/null
@@ -1,124 +0,0 @@
-module Main exposing (..)
-
-import Api.Auth exposing (..)
-import Api.Decoders exposing (..)
-import Api.Schedule exposing (..)
-import Api.SchoolYear exposing (..)
-import Api.TimeEntry exposing (..)
-import Api.User exposing (..)
-import Browser
-import Task
-import Time
-import Types.Model exposing (..)
-import Types.Msg exposing (Msg(..))
-import Types.Page exposing (..)
-import Update.Update exposing (update)
-import Utils.Ports exposing (..)
-import View.View exposing (view)
-
-
-
--- MAIN
-
-
-main : Program Flags Model Msg
-main =
- Browser.element
- { init = init
- , update = update
- , subscriptions = subscriptions
- , view = view
- }
-
-
-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 = initialPage
- , activeTab = ScheduleTab
- , username = ""
- , password = ""
- , token = flags.token
- , isAdmin = flags.isAdmin
- , schedules = []
- , users = []
- , timeEntries = []
- , weeklyHours = []
- , yearlyHoursSummary = []
- , selectedEntries = []
- , currentWeek = 1
- , currentYear = 2025
- , currentTime = Time.millisToPosix 0
- , zone = Time.utc
- , newSchedule = NewSchedule "" "" "" "lesson" ""
- , newUser = NewUser "" "" False
- , error = Nothing
- , weekEditMode = False
- , hasEntriesForCurrentWeek = False
- , weekDates = Nothing
- , userWeeklySummary = Nothing
- , editingTimeEntryId = Nothing
- , editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
- , editingUserId = Nothing
- , editingUserWorkHours = ""
- , resetPasswordUserId = Nothing
- , resetPasswordNew = ""
- , pendingDeleteId = Nothing
- , selectedUserId = Nothing
- , userWorkHoursInput = ""
- , userPasswordInput = ""
- , isProcessing = False
- , mobileMenuOpen = False
- , adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
- , schoolYears = []
- , newSchoolYear = NewSchoolYear "" "" ""
- , activeSchoolYear = Nothing
- , editingSchoolYearId = Nothing
- , toasts = []
- , nextToastId = 0
- }
-
- cmd =
- case flags.token of
- Just token ->
- Cmd.batch
- [ Task.perform SetTime Time.now
- , fetchSchedules (Just token)
- , fetchYearlyHoursSummary token
- , if flags.isAdmin then
- Cmd.batch
- [ fetchSchoolYears token
- , fetchUsers token
- , fetchAllTimeEntries token
- ]
-
- else
- fetchMyInfo token
- ]
-
- Nothing ->
- Task.perform SetTime Time.now
- in
- ( model, cmd )
-
-
-
--- SUBSCRIPTIONS
-
-
-subscriptions : Model -> Sub Msg
-subscriptions model =
- confirmDeleteResponse DeleteConfirmed
diff --git a/frontend/src/Ports.elm b/frontend/src/Ports.elm
deleted file mode 100644
index 4ede617..0000000
--- a/frontend/src/Ports.elm
+++ /dev/null
@@ -1,11 +0,0 @@
-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
-
diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm
deleted file mode 100644
index aae29d0..0000000
--- a/frontend/src/Types/Api.elm
+++ /dev/null
@@ -1,17 +0,0 @@
-module Types.Api exposing
- ( ApiError
- , LoginResult
- )
-
-
-type alias LoginResult =
- { token : String
- , username : String
- , isAdmin : Bool
- }
-
-
-type alias ApiError =
- { code : String
- , message : String
- }
diff --git a/frontend/src/Types/Model.elm b/frontend/src/Types/Model.elm
deleted file mode 100644
index 64911d6..0000000
--- a/frontend/src/Types/Model.elm
+++ /dev/null
@@ -1,218 +0,0 @@
-module Types.Model exposing
- ( AdminManualEntry
- , EditingTimeEntry
- , Flags
- , Model
- , NewSchedule
- , NewSchoolYear
- , NewUser
- , Schedule
- , SchoolYear
- , SelectedEntry
- , TimeEntry
- , Toast
- , ToastType(..)
- , User
- , WeekDates
- , WeeklyHours
- , WeeklySummary
- , YearlyHoursSummary
- )
-
-import Time
-import Types.Page exposing (AdminTab, Page)
-
-
-type alias Model =
- { page : Page
- , activeTab : AdminTab
- , username : String
- , password : String
- , token : Maybe String
- , isAdmin : Bool
- , schedules : List Schedule
- , users : List User
- , timeEntries : List TimeEntry
- , weeklyHours : List WeeklyHours
- , yearlyHoursSummary : List YearlyHoursSummary
- , selectedEntries : List SelectedEntry
- , currentWeek : Int
- , currentYear : Int
- , weekDates : Maybe WeekDates
- , currentTime : Time.Posix
- , zone : Time.Zone
- , newSchedule : NewSchedule
- , newUser : NewUser
- , error : Maybe String
- , weekEditMode : Bool
- , hasEntriesForCurrentWeek : Bool
- , 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
- , adminManualEntryForm : AdminManualEntry
- , schoolYears : List SchoolYear
- , newSchoolYear : NewSchoolYear
- , activeSchoolYear : Maybe SchoolYear
- , editingSchoolYearId : Maybe Int
- , toasts : List Toast
- , nextToastId : Int
- }
-
-
-type ToastType
- = ErrorToast
- | SuccessToast
- | InfoToast
- | WarningToast
-
-
-type alias Toast =
- { id : Int
- , message : String
- , toastType : ToastType
- , dismissible : Bool
- }
-
-
-type alias Flags =
- { token : Maybe String
- , isAdmin : Bool
- }
-
-
-type alias Schedule =
- { id : Int
- , dayOfWeek : Int
- , startTime : String
- , endTime : String
- , scheduleType : String
- , title : String
- }
-
-
-type alias User =
- { id : Int
- , username : String
- , isAdmin : Bool
- , yearlyWorkHours : Float
- }
-
-
-type alias TimeEntry =
- { id : Int
- , userId : Int
- , scheduleId : Int
- , date : String
- , entryType : String
- , username : String
- , startTime : String
- , endTime : String
- }
-
-
-type alias SelectedEntry =
- { scheduleId : Int
- , dayOfWeek : Int
- }
-
-
-type alias NewSchedule =
- { dayOfWeek : String
- , startTime : String
- , endTime : String
- , scheduleType : String
- , title : String
- }
-
-
-type alias NewUser =
- { username : String
- , password : String
- , isAdmin : Bool
- }
-
-
-type alias WeekDates =
- { year : Int
- , week : Int
- , dates : List ( String, String )
- , range : String
- }
-
-
-type alias WeeklySummary =
- { userId : Int
- , username : String
- , year : Int
- , week : Int
- , totalHours : Float
- , targetHours : Float
- , remainingHours : Float
- }
-
-
-type alias EditingTimeEntry =
- { entryId : Int
- , date : String
- , startTime : String
- , endTime : String
- , entryType : String
- }
-
-
-type alias WeeklyHours =
- { userId : Int
- , username : String
- , year : Int
- , week : Int
- , totalHours : Float
- , targetHours : Float
- , remainingHours : Float
- }
-
-
-type alias YearlyHoursSummary =
- { userId : Int
- , username : String
- , year : Int
- , week : Int
- , totalHours : Float
- , yearlyTarget : Float
- , yearlyActual : Float
- , weeklyTarget : Float
- , remainingYearly : Float
- }
-
-
-type alias AdminManualEntry =
- { selectedUserId : Maybe Int
- , date : String
- , hours : String
- , entryType : String
- }
-
-
-type alias SchoolYear =
- { id : Int
- , name : String
- , startDate : String
- , endDate : String
- , isActive : Bool
- }
-
-
-type alias NewSchoolYear =
- { name : String
- , startDate : String
- , endDate : String
- }
diff --git a/frontend/src/Types/Msg.elm b/frontend/src/Types/Msg.elm
deleted file mode 100644
index 4158571..0000000
--- a/frontend/src/Types/Msg.elm
+++ /dev/null
@@ -1,133 +0,0 @@
-module Types.Msg exposing (Msg(..))
-
-import Bytes exposing (Bytes)
-import Http
-import Time
-import Types.Api exposing (LoginResult)
-import Types.Model
- exposing
- ( Schedule
- , SchoolYear
- , TimeEntry
- , ToastType(..)
- , User
- , WeekDates
- , WeeklyHours
- , WeeklySummary
- , YearlyHoursSummary
- )
-import Types.Page exposing (AdminTab)
-
-
-type Msg
- = UpdateUsername String
- | UpdatePassword String
- | Login
- | LoginResponse (Result Http.Error LoginResult)
- | Logout
- | SetTime Time.Posix
- | FetchSchedules
- | SchedulesReceived (Result Http.Error (List Schedule))
- | ToggleScheduleSelection Int Int
- | SaveTimeEntries
- | TimeEntriesSaved (Result Http.Error ())
- | PreviousWeek
- | NextWeek
- | EnableEditMode
- | DisableEditMode
- | DeleteWeekEntries
- | WeekEntriesDeleted (Result Http.Error ())
- | SwitchTab AdminTab
- | UpdateNewScheduleDay String
- | UpdateNewScheduleStart String
- | UpdateNewScheduleEnd String
- | UpdateNewScheduleType String
- | UpdateNewScheduleTitle String
- | CreateSchedule
- | ScheduleCreated (Result Http.Error ())
- | DeleteSchedule Int
- | ScheduleDeleted (Result Http.Error ())
- | UpdateNewUsername String
- | UpdateNewPassword String
- | UpdateNewUserAdmin Bool
- | CreateUser
- | UserCreated (Result Http.Error ())
- | DeleteUser Int
- | UserDeleted (Result Http.Error ())
- | FetchUsers
- | UsersReceived (Result Http.Error (List User))
- | FetchMyTimeEntries
- | MyTimeEntriesReceived (Result Http.Error (List TimeEntry))
- | FetchAllTimeEntries
- | AllTimeEntriesReceived (Result Http.Error (List TimeEntry))
- | FetchWeeklyHours
- | WeeklyHoursReceived (Result Http.Error (List WeeklyHours))
- | FetchYearlyHoursSummary
- | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary))
- | FetchWeekDates
- | WeekDatesReceived (Result Http.Error WeekDates)
- | CheckWeekHasEntries
- | WeekHasEntriesReceived (Result Http.Error Bool)
- | 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
- | UpdateEditingTimeEntryStartTime String
- | UpdateEditingTimeEntryEndTime String
- | UpdateEditingTimeEntryType String
- | SaveEditingTimeEntry
- | SelectUserForManagement Int
- | UpdateUserWorkHours String
- | UpdateUserPassword String
- | SaveUserPassword
- | UserPasswordSaved (Result Http.Error ())
- | ToggleMobileMenu
- | CloseMobileMenu
- | SelectUserForManualEntry Int
- | UpdateManualEntryDate String
- | UpdateManualEntryHours String
- | UpdateManualEntryType String
- | SaveAdminTimeEntry
- | AdminTimeEntrySaved (Result Http.Error ())
- | FetchMyInfo
- | MyInfoReceived (Result Http.Error User)
- | FetchSchoolYears
- | SchoolYearsReceived (Result Http.Error (List SchoolYear))
- | FetchActiveSchoolYear
- | ActiveSchoolYearReceived (Result Http.Error SchoolYear)
- | UpdateNewSchoolYearName String
- | UpdateNewSchoolYearStart String
- | UpdateNewSchoolYearEnd String
- | CreateSchoolYear
- | SchoolYearCreated (Result Http.Error ())
- | ActivateSchoolYear Int
- | SchoolYearActivated (Result Http.Error ())
- | DeleteSchoolYear Int
- | SchoolYearDeleted (Result Http.Error ())
- | DownloadYearlySummaryPDF
- | YearlySummaryPDFReceived (Result Http.Error Bytes)
- | ShowToast String ToastType
- | DismissToast Int
- | AutoDismissToast Int
diff --git a/frontend/src/Types/Page.elm b/frontend/src/Types/Page.elm
deleted file mode 100644
index 5b41054..0000000
--- a/frontend/src/Types/Page.elm
+++ /dev/null
@@ -1,17 +0,0 @@
-module Types.Page exposing
- ( AdminTab(..)
- , Page(..)
- )
-
-
-type Page
- = LoginPage
- | UserDashboard
- | AdminDashboard
-
-
-type AdminTab
- = ScheduleTab
- | UsersTab
- | TimeEntriesTab
- | SchoolYearsTab
diff --git a/frontend/src/Update/AuthUpdate.elm b/frontend/src/Update/AuthUpdate.elm
deleted file mode 100644
index 20a1fbc..0000000
--- a/frontend/src/Update/AuthUpdate.elm
+++ /dev/null
@@ -1,115 +0,0 @@
-module Update.AuthUpdate exposing
- ( handleLogin
- , handleLoginResponse
- , handleLogout
- )
-
-import Api.Auth
-import Api.Schedule
-import Api.SchoolYear
-import Api.TimeEntry
-import Api.User
-import Http
-import Json.Encode as Encode
-import Task
-import Types.Model exposing (Model, ToastType(..))
-import Types.Msg exposing (Msg(..))
-import Types.Page exposing (Page(..))
-import Utils.DateUtils exposing (getISOWeekFromPosix)
-import Utils.Ports exposing (removeToken, saveToken)
-
-
-handleLogin : Model -> ( Model, Cmd Msg )
-handleLogin model =
- if model.isProcessing then
- ( model, Cmd.none )
-
- else
- ( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password )
-
-
-handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg )
-handleLoginResponse result model =
- case result of
- Ok loginResult ->
- let
- newPage =
- if loginResult.isAdmin then
- AdminDashboard
-
- else
- UserDashboard
-
- ( year, week ) =
- getISOWeekFromPosix model.currentTime
-
- tokenData =
- Encode.object
- [ ( "token", Encode.string loginResult.token )
- , ( "isAdmin", Encode.bool loginResult.isAdmin )
- ]
- in
- ( { model
- | token = Just loginResult.token
- , username = loginResult.username
- , isAdmin = loginResult.isAdmin
- , page = newPage
- , error = Nothing
- , isProcessing = False
- }
- , Cmd.batch
- [ saveToken tokenData
- , Api.Schedule.fetchSchedules (Just loginResult.token)
- , Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ())
- , if not loginResult.isAdmin then
- Cmd.batch
- [ Api.TimeEntry.fetchMyTimeEntries loginResult.token
- , Api.TimeEntry.fetchWeekDates loginResult.token year week
- , Api.TimeEntry.checkWeekHasEntries loginResult.token year week
- , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
- , Api.User.fetchMyInfo loginResult.token
- ]
-
- else
- Cmd.batch
- [ Api.TimeEntry.fetchMyTimeEntries loginResult.token
- , Api.TimeEntry.fetchWeekDates loginResult.token year week
- , Api.TimeEntry.checkWeekHasEntries loginResult.token year week
- , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
- ]
- ]
- )
-
- Err err ->
- let
- errorMsg =
- case err of
- Http.BadStatus 401 ->
- "Benutzername oder Passwort ungültig"
-
- Http.Timeout ->
- "Zeitüberschreitung - bitte erneut versuchen"
-
- Http.NetworkError ->
- "Netzwerkfehler - bitte Verbindung prüfen"
-
- _ ->
- "Anmeldung fehlgeschlagen"
- in
- ( { model | isProcessing = False }
- , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ())
- )
-
-
-handleLogout : Model -> ( Model, Cmd Msg )
-handleLogout model =
- ( { model
- | page = LoginPage
- , token = Nothing
- , isAdmin = False
- , username = ""
- , password = ""
- , isProcessing = False
- }
- , removeToken ()
- )
diff --git a/frontend/src/Update/ScheduleUpdate.elm b/frontend/src/Update/ScheduleUpdate.elm
deleted file mode 100644
index 2312e13..0000000
--- a/frontend/src/Update/ScheduleUpdate.elm
+++ /dev/null
@@ -1,244 +0,0 @@
-module Update.ScheduleUpdate exposing
- ( handleCreateSchedule
- , handleDeleteSchedule
- , handleDeleteWeekEntries
- , handleDisableEditMode
- , handleEnableEditMode
- , handleSaveTimeEntries
- , handleScheduleCreated
- , handleScheduleDeleted
- , handleSchedulesReceived
- , handleTimeEntriesSaved
- , handleToggleScheduleSelection
- , handleWeekEntriesDeleted
- )
-
-import Api.Schedule
-import Api.TimeEntry
-import Http
-import Task
-import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..))
-import Types.Msg exposing (Msg(..))
-import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate)
-
-
-handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg )
-handleToggleScheduleSelection scheduleId dayOfWeek model =
- let
- entry =
- { scheduleId = scheduleId, dayOfWeek = dayOfWeek }
-
- newSelected =
- 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
- entry :: model.selectedEntries
- in
- ( { model | selectedEntries = newSelected }, Cmd.none )
-
-
-handleSaveTimeEntries : Model -> ( Model, Cmd Msg )
-handleSaveTimeEntries model =
- case model.token of
- Just token ->
- ( { model | error = Nothing }
- , Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates
- )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleTimeEntriesSaved result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | error = Nothing
- , weekEditMode = False
- , hasEntriesForCurrentWeek = True
- }
- , Cmd.batch
- [ Api.TimeEntry.fetchMyTimeEntries token
- , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleEnableEditMode : Model -> ( Model, Cmd Msg )
-handleEnableEditMode model =
- let
- currentWeekEntries =
- List.filter
- (\e ->
- let
- ( entryYear, entryWeek ) =
- getYearWeekFromDate e.date
- in
- entryWeek == model.currentWeek && entryYear == model.currentYear
- )
- model.timeEntries
-
- preSelectedEntries =
- List.map
- (\entry ->
- let
- parts =
- String.split "-" entry.date
-
- year =
- parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
-
- month =
- parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
-
- day =
- parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
-
- dayOfWeek =
- getDayOfWeek year month day
- in
- { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek }
- )
- currentWeekEntries
- in
- ( { model
- | weekEditMode = True
- , selectedEntries = preSelectedEntries
- }
- , Cmd.none
- )
-
-
-handleDisableEditMode : Model -> ( Model, Cmd Msg )
-handleDisableEditMode model =
- ( { model | weekEditMode = False }, Cmd.none )
-
-
-handleDeleteWeekEntries : Model -> ( Model, Cmd Msg )
-handleDeleteWeekEntries model =
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleWeekEntriesDeleted result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | weekEditMode = True
- , selectedEntries = []
- , hasEntriesForCurrentWeek = False
- }
- , Cmd.batch
- [ Api.TimeEntry.fetchMyTimeEntries token
- , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleCreateSchedule : Model -> ( Model, Cmd Msg )
-handleCreateSchedule model =
- if
- String.isEmpty model.newSchedule.dayOfWeek
- || String.isEmpty model.newSchedule.startTime
- || String.isEmpty model.newSchedule.endTime
- then
- ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
-
- else
- case model.token of
- Just token ->
- ( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleScheduleCreated result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- let
- emptySchedule =
- NewSchedule "" "" "" "lesson" ""
- in
- ( { model
- | newSchedule = emptySchedule
- , error = Nothing
- , isProcessing = False
- }
- , Cmd.batch
- [ Api.Schedule.fetchSchedules model.token
- , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( { model | isProcessing = False }, Cmd.none )
-
-
-handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg )
-handleDeleteSchedule scheduleId model =
- case model.token of
- Just token ->
- ( model, Api.Schedule.deleteSchedule token scheduleId )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleScheduleDeleted result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model | error = Nothing }
- , Cmd.batch
- [ Api.Schedule.fetchSchedules (Just token)
- , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg )
-handleSchedulesReceived result model =
- case result of
- Ok schedules ->
- ( { model | schedules = schedules }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
diff --git a/frontend/src/Update/SchoolYearUpdate.elm b/frontend/src/Update/SchoolYearUpdate.elm
deleted file mode 100644
index 0de741d..0000000
--- a/frontend/src/Update/SchoolYearUpdate.elm
+++ /dev/null
@@ -1,139 +0,0 @@
-module Update.SchoolYearUpdate exposing
- ( handleActivateSchoolYear
- , handleActiveSchoolYearReceived
- , handleCreateSchoolYear
- , handleDeleteSchoolYear
- , handleSchoolYearActivated
- , handleSchoolYearCreated
- , handleSchoolYearDeleted
- , handleSchoolYearsReceived
- )
-
-import Api.SchoolYear
-import Http
-import Task
-import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..))
-import Types.Msg exposing (Msg(..))
-
-
-handleCreateSchoolYear : Model -> ( Model, Cmd Msg )
-handleCreateSchoolYear model =
- if
- String.isEmpty model.newSchoolYear.name
- || String.isEmpty model.newSchoolYear.startDate
- || String.isEmpty model.newSchoolYear.endDate
- then
- ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
-
- else
- case model.token of
- Just token ->
- ( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleSchoolYearCreated result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | newSchoolYear = NewSchoolYear "" "" ""
- , error = Nothing
- , isProcessing = False
- }
- , Cmd.batch
- [ Api.SchoolYear.fetchSchoolYears token
- , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( { model | isProcessing = False }, Cmd.none )
-
-
-handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg )
-handleActivateSchoolYear id model =
- case model.token of
- Just token ->
- ( model, Api.SchoolYear.activateSchoolYear token id )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleSchoolYearActivated result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model | error = Nothing }
- , Cmd.batch
- [ Api.SchoolYear.fetchSchoolYears token
- , Api.SchoolYear.fetchActiveSchoolYear token
- , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg )
-handleDeleteSchoolYear id model =
- case model.token of
- Just token ->
- ( model, Api.SchoolYear.deleteSchoolYear token id )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleSchoolYearDeleted result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model | error = Nothing }
- , Cmd.batch
- [ Api.SchoolYear.fetchSchoolYears token
- , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg )
-handleSchoolYearsReceived result model =
- case result of
- Ok years ->
- ( { model | schoolYears = years }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg )
-handleActiveSchoolYearReceived result model =
- case result of
- Ok year ->
- ( { model | activeSchoolYear = Just year }, Cmd.none )
-
- Err _ ->
- ( { model | activeSchoolYear = Nothing }, Cmd.none )
diff --git a/frontend/src/Update/TimeEntryUpdate.elm b/frontend/src/Update/TimeEntryUpdate.elm
deleted file mode 100644
index a794944..0000000
--- a/frontend/src/Update/TimeEntryUpdate.elm
+++ /dev/null
@@ -1,189 +0,0 @@
-module Update.TimeEntryUpdate exposing
- ( handleAdminTimeEntrySaved
- , handleAllTimeEntriesReceived
- , handleConfirmDeleteTimeEntry
- , handleEditTimeEntry
- , handleMyTimeEntriesReceived
- , handleSaveAdminTimeEntry
- , handleSaveEditTimeEntry
- , handleTimeEntryDeleted
- , handleTimeEntrySaved
- , handleYearlyHoursSummaryReceived
- )
-
-import Api.TimeEntry
-import Http
-import Task
-import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary)
-import Types.Msg exposing (Msg(..))
-import Utils.DateUtils exposing (getYearWeekFromDate)
-import Utils.Ports exposing (confirmDelete)
-
-
-handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
-handleMyTimeEntriesReceived result model =
- case result of
- Ok entries ->
- let
- hasEntries =
- List.any
- (\e ->
- let
- ( entryYear, entryWeek ) =
- getYearWeekFromDate e.date
- in
- entryWeek == model.currentWeek && entryYear == model.currentYear
- )
- entries
- in
- ( { model
- | timeEntries = entries
- , hasEntriesForCurrentWeek = hasEntries
- , weekEditMode = False
- }
- , Cmd.none
- )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
-handleAllTimeEntriesReceived result model =
- case result of
- Ok entries ->
- ( { model | timeEntries = entries }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg )
-handleEditTimeEntry entryId model =
- case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of
- Just entry ->
- ( { model
- | editingTimeEntryId = Just entryId
- , editingTimeEntry =
- { entryId = entryId
- , date = entry.date
- , startTime = entry.startTime
- , endTime = entry.endTime
- , entryType = entry.entryType
- }
- }
- , Cmd.none
- )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg )
-handleSaveEditTimeEntry model =
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleTimeEntrySaved result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | editingTimeEntryId = Nothing
- , pendingDeleteId = Nothing
- , error = Nothing
- }
- , Cmd.batch
- [ Api.TimeEntry.fetchAllTimeEntries token
- , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleTimeEntryDeleted result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | editingTimeEntryId = Nothing
- , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
- , pendingDeleteId = Nothing
- , error = Nothing
- }
- , Cmd.batch
- [ Api.TimeEntry.fetchAllTimeEntries token
- , Api.TimeEntry.fetchYearlyHoursSummary token
- , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( { model | pendingDeleteId = Nothing }, Cmd.none )
-
-
-handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg )
-handleConfirmDeleteTimeEntry entryId model =
- ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" )
-
-
-handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg )
-handleSaveAdminTimeEntry model =
- case model.token of
- Just token ->
- ( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleAdminTimeEntrySaved result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
- , error = Nothing
- , isProcessing = False
- }
- , Cmd.batch
- [ Api.TimeEntry.fetchAllTimeEntries token
- , Api.TimeEntry.fetchYearlyHoursSummary token
- , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( { model | isProcessing = False }, Cmd.none )
-
-
-handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg )
-handleYearlyHoursSummaryReceived result model =
- case result of
- Ok summary ->
- ( { model | yearlyHoursSummary = summary }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
diff --git a/frontend/src/Update/Update.elm b/frontend/src/Update/Update.elm
deleted file mode 100644
index f384b8c..0000000
--- a/frontend/src/Update/Update.elm
+++ /dev/null
@@ -1,811 +0,0 @@
-module Update.Update exposing (update)
-
-import Api.Schedule
-import Api.SchoolYear
-import Api.TimeEntry
-import Api.User
-import File.Download
-import Process
-import Task
-import Time
-import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..))
-import Types.Msg exposing (Msg(..))
-import Types.Page exposing (AdminTab(..), Page(..))
-import Update.AuthUpdate as Auth
-import Update.ScheduleUpdate as Schedule
-import Update.SchoolYearUpdate as SchoolYear
-import Update.TimeEntryUpdate as TimeEntry
-import Update.UserUpdate as User
-import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek)
-import Utils.Ports
-
-
-update : Msg -> Model -> ( Model, Cmd Msg )
-update msg model =
- case msg of
- -- Mobile Menu
- ToggleMobileMenu ->
- ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none )
-
- CloseMobileMenu ->
- ( { model | mobileMenuOpen = False }, Cmd.none )
-
- -- Auth
- UpdateUsername username ->
- ( { model | username = username }, Cmd.none )
-
- UpdatePassword password ->
- ( { model | password = password }, Cmd.none )
-
- Login ->
- Auth.handleLogin model
-
- LoginResponse result ->
- Auth.handleLoginResponse result model
-
- Logout ->
- Auth.handleLogout model
-
- -- Time
- SetTime time ->
- let
- ( year, week ) =
- getISOWeekFromPosix time
-
- cmds =
- case model.token of
- Just token ->
- if model.page == UserDashboard || model.page == LoginPage then
- Cmd.batch
- [ Api.TimeEntry.checkWeekHasEntries token year week
- , Api.TimeEntry.fetchWeekDates token year week
- , Api.TimeEntry.fetchMyTimeEntries token
- ]
-
- else
- Cmd.none
-
- Nothing ->
- Cmd.none
- in
- ( { model
- | currentTime = time
- , currentWeek = week
- , currentYear = year
- }
- , cmds
- )
-
- -- Schedules
- FetchSchedules ->
- ( model, Api.Schedule.fetchSchedules model.token )
-
- SchedulesReceived result ->
- Schedule.handleSchedulesReceived result model
-
- ToggleScheduleSelection scheduleId dayOfWeek ->
- Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model
-
- SaveTimeEntries ->
- Schedule.handleSaveTimeEntries model
-
- TimeEntriesSaved result ->
- Schedule.handleTimeEntriesSaved result model
-
- EnableEditMode ->
- Schedule.handleEnableEditMode model
-
- DisableEditMode ->
- Schedule.handleDisableEditMode model
-
- DeleteWeekEntries ->
- Schedule.handleDeleteWeekEntries model
-
- WeekEntriesDeleted result ->
- Schedule.handleWeekEntriesDeleted result model
-
- CreateSchedule ->
- Schedule.handleCreateSchedule model
-
- ScheduleCreated result ->
- Schedule.handleScheduleCreated result model
-
- DeleteSchedule scheduleId ->
- Schedule.handleDeleteSchedule scheduleId model
-
- ScheduleDeleted result ->
- Schedule.handleScheduleDeleted result model
-
- -- Week Navigation
- PreviousWeek ->
- let
- ( newYear, newWeek ) =
- previousWeek model.currentYear model.currentWeek
- in
- ( { model
- | currentWeek = newWeek
- , currentYear = newYear
- , selectedEntries = []
- , weekEditMode = False
- }
- , case model.token of
- Just token ->
- Cmd.batch
- [ Api.TimeEntry.fetchWeekDates token newYear newWeek
- , Api.TimeEntry.checkWeekHasEntries token newYear newWeek
- ]
-
- Nothing ->
- Cmd.none
- )
-
- NextWeek ->
- let
- ( newYear, newWeek ) =
- nextWeek model.currentYear model.currentWeek
- in
- ( { model
- | currentWeek = newWeek
- , currentYear = newYear
- , selectedEntries = []
- , weekEditMode = False
- }
- , case model.token of
- Just token ->
- Cmd.batch
- [ Api.TimeEntry.fetchWeekDates token newYear newWeek
- , Api.TimeEntry.checkWeekHasEntries token newYear newWeek
- ]
-
- Nothing ->
- Cmd.none
- )
-
- FetchWeekDates ->
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek )
-
- Nothing ->
- ( model, Cmd.none )
-
- WeekDatesReceived result ->
- case result of
- Ok weekDates ->
- ( { model | weekDates = Just weekDates }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
- CheckWeekHasEntries ->
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek )
-
- Nothing ->
- ( model, Cmd.none )
-
- WeekHasEntriesReceived result ->
- case result of
- Ok hasEntries ->
- ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
- -- Admin Tabs
- SwitchTab tab ->
- let
- cmd =
- case tab of
- UsersTab ->
- case model.token of
- Just token ->
- Api.User.fetchUsers token
-
- Nothing ->
- Cmd.none
-
- TimeEntriesTab ->
- case model.token of
- Just token ->
- Cmd.batch
- [ Api.TimeEntry.fetchAllTimeEntries token
- , Api.TimeEntry.fetchYearlyHoursSummary token
- ]
-
- Nothing ->
- Cmd.none
-
- SchoolYearsTab ->
- case model.token of
- Just token ->
- Cmd.batch
- [ Api.SchoolYear.fetchSchoolYears token
- , Api.SchoolYear.fetchActiveSchoolYear token
- ]
-
- Nothing ->
- Cmd.none
-
- _ ->
- Cmd.none
- in
- ( { model | activeTab = tab, mobileMenuOpen = False }, cmd )
-
- -- Schedule Form
- UpdateNewScheduleDay day ->
- let
- oldSchedule =
- model.newSchedule
-
- newSchedule =
- { oldSchedule | dayOfWeek = day }
- in
- ( { model | newSchedule = newSchedule }, Cmd.none )
-
- UpdateNewScheduleStart time ->
- let
- oldSchedule =
- model.newSchedule
-
- newSchedule =
- { oldSchedule | startTime = time }
- in
- ( { model | newSchedule = newSchedule }, Cmd.none )
-
- UpdateNewScheduleEnd time ->
- let
- oldSchedule =
- model.newSchedule
-
- newSchedule =
- { oldSchedule | endTime = time }
- in
- ( { model | newSchedule = newSchedule }, Cmd.none )
-
- UpdateNewScheduleType scheduleType ->
- let
- oldSchedule =
- model.newSchedule
-
- newSchedule =
- { oldSchedule | scheduleType = scheduleType }
- in
- ( { model | newSchedule = newSchedule }, Cmd.none )
-
- UpdateNewScheduleTitle title ->
- let
- oldSchedule =
- model.newSchedule
-
- newSchedule =
- { oldSchedule | title = title }
- in
- ( { model | newSchedule = newSchedule }, Cmd.none )
-
- -- Users
- UpdateNewUsername username ->
- let
- oldUser =
- model.newUser
-
- newUser =
- { oldUser | username = username }
- in
- ( { model | newUser = newUser }, Cmd.none )
-
- UpdateNewPassword password ->
- let
- oldUser =
- model.newUser
-
- newUser =
- { oldUser | password = password }
- in
- ( { model | newUser = newUser }, Cmd.none )
-
- UpdateNewUserAdmin isAdmin ->
- let
- oldUser =
- model.newUser
-
- newUser =
- { oldUser | isAdmin = isAdmin }
- in
- ( { model | newUser = newUser }, Cmd.none )
-
- CreateUser ->
- User.handleCreateUser model
-
- UserCreated result ->
- User.handleUserCreated result model
-
- DeleteUser userId ->
- User.handleDeleteUser userId model
-
- UserDeleted result ->
- User.handleUserDeleted result model
-
- FetchUsers ->
- case model.token of
- Just token ->
- ( model, Api.User.fetchUsers token )
-
- Nothing ->
- ( model, Cmd.none )
-
- UsersReceived result ->
- User.handleUsersReceived result model
-
- EditUserWorkHours userId ->
- User.handleEditUserWorkHours userId model
-
- CancelEditUserWorkHours ->
- ( { model
- | editingUserId = Nothing
- , editingUserWorkHours = ""
- }
- , Cmd.none
- )
-
- UpdateEditUserWorkHours hours ->
- ( { model | editingUserWorkHours = hours }, Cmd.none )
-
- SaveUserWorkHours ->
- User.handleSaveUserWorkHours model
-
- UserWorkHoursSaved result ->
- User.handleUserWorkHoursSaved result model
-
- ResetUserPassword userId ->
- User.handleResetUserPassword userId model
-
- CancelResetPassword ->
- ( { model
- | resetPasswordUserId = Nothing
- , resetPasswordNew = ""
- }
- , Cmd.none
- )
-
- UpdateResetPasswordNew password ->
- ( { model | resetPasswordNew = password }, Cmd.none )
-
- SaveResetPassword ->
- User.handleSaveResetPassword model
-
- ResetPasswordSaved result ->
- User.handleResetPasswordSaved result model
-
- UpdateUserWorkHours input ->
- ( { model | userWorkHoursInput = input }, Cmd.none )
-
- UpdateUserPassword input ->
- ( { model | userPasswordInput = input }, Cmd.none )
-
- SaveUserPassword ->
- case ( model.token, model.selectedUserId ) of
- ( Just token, Just userId ) ->
- if String.length model.userPasswordInput > 0 then
- ( model, Api.User.resetUserPassword token userId model.userPasswordInput )
-
- else
- ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
-
- _ ->
- ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
-
- UserPasswordSaved result ->
- case result of
- Ok _ ->
- ( { model
- | userPasswordInput = ""
- , selectedUserId = Nothing
- , error = Nothing
- }
- , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ())
- )
-
- Err err ->
- ( model, Cmd.none )
-
- SelectUserForManagement userId ->
- ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none )
-
- -- Time Entries
- FetchMyTimeEntries ->
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.fetchMyTimeEntries token )
-
- Nothing ->
- ( model, Cmd.none )
-
- MyTimeEntriesReceived result ->
- TimeEntry.handleMyTimeEntriesReceived result model
-
- FetchAllTimeEntries ->
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.fetchAllTimeEntries token )
-
- Nothing ->
- ( model, Cmd.none )
-
- AllTimeEntriesReceived result ->
- TimeEntry.handleAllTimeEntriesReceived result model
-
- EditTimeEntry entryId ->
- TimeEntry.handleEditTimeEntry entryId model
-
- CancelEditTimeEntry ->
- ( { model
- | editingTimeEntryId = Nothing
- , editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
- }
- , Cmd.none
- )
-
- UpdateEditTimeEntryDate date ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | date = date }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- UpdateEditTimeEntryStartTime time ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | startTime = time }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- UpdateEditTimeEntryEndTime time ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | endTime = time }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- UpdateEditTimeEntryType entryType ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | entryType = entryType }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- SaveEditTimeEntry ->
- TimeEntry.handleSaveEditTimeEntry model
-
- TimeEntrySaved result ->
- TimeEntry.handleTimeEntrySaved result model
-
- TimeEntryDeleted result ->
- TimeEntry.handleTimeEntryDeleted result model
-
- ConfirmDeleteTimeEntry entryId ->
- TimeEntry.handleConfirmDeleteTimeEntry entryId model
-
- StartEditingTimeEntry entryId entry ->
- ( { model
- | editingTimeEntryId = Just entryId
- , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType
- }
- , Cmd.none
- )
-
- CancelEditingTimeEntry ->
- ( { model
- | editingTimeEntryId = Nothing
- , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
- }
- , Cmd.none
- )
-
- UpdateEditingTimeEntryDate date ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | date = date }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- UpdateEditingTimeEntryStartTime time ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | startTime = time }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- UpdateEditingTimeEntryEndTime time ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | endTime = time }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- UpdateEditingTimeEntryType entryType ->
- let
- old =
- model.editingTimeEntry
-
- new =
- { old | entryType = entryType }
- in
- ( { model | editingTimeEntry = new }, Cmd.none )
-
- SaveEditingTimeEntry ->
- case ( model.token, model.editingTimeEntryId ) of
- ( Just token, Just entryId ) ->
- ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
-
- _ ->
- ( model, Cmd.none )
-
- -- Weekly Hours
- FetchWeeklyHours ->
- case model.token of
- Just token ->
- ( model, Cmd.none )
-
- Nothing ->
- ( model, Cmd.none )
-
- WeeklyHoursReceived result ->
- case result of
- Ok hours ->
- ( { model | weeklyHours = hours }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
- MyWeeklySummaryReceived result ->
- case result of
- Ok summary ->
- ( { model | userWeeklySummary = Just summary }, Cmd.none )
-
- Err _ ->
- ( { model | userWeeklySummary = Nothing }, Cmd.none )
-
- -- Yearly Hours
- FetchYearlyHoursSummary ->
- case model.token of
- Just token ->
- ( model, Api.TimeEntry.fetchYearlyHoursSummary token )
-
- Nothing ->
- ( model, Cmd.none )
-
- YearlyHoursSummaryReceived result ->
- TimeEntry.handleYearlyHoursSummaryReceived result model
-
- -- Admin Manual Entry
- SelectUserForManualEntry userId ->
- let
- form =
- model.adminManualEntryForm
- in
- ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none )
-
- UpdateManualEntryDate date ->
- let
- form =
- model.adminManualEntryForm
- in
- ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none )
-
- UpdateManualEntryHours hours ->
- let
- form =
- model.adminManualEntryForm
- in
- ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none )
-
- UpdateManualEntryType entryType ->
- let
- form =
- model.adminManualEntryForm
- in
- ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none )
-
- SaveAdminTimeEntry ->
- TimeEntry.handleSaveAdminTimeEntry model
-
- AdminTimeEntrySaved result ->
- TimeEntry.handleAdminTimeEntrySaved result model
-
- -- My Info
- FetchMyInfo ->
- case model.token of
- Just token ->
- ( model, Api.User.fetchMyInfo token )
-
- Nothing ->
- ( model, Cmd.none )
-
- MyInfoReceived result ->
- case result of
- Ok user ->
- ( { model | users = [ user ] }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
- -- School Years
- FetchSchoolYears ->
- case model.token of
- Just token ->
- ( model, Api.SchoolYear.fetchSchoolYears token )
-
- Nothing ->
- ( model, Cmd.none )
-
- SchoolYearsReceived result ->
- SchoolYear.handleSchoolYearsReceived result model
-
- FetchActiveSchoolYear ->
- case model.token of
- Just token ->
- ( model, Api.SchoolYear.fetchActiveSchoolYear token )
-
- Nothing ->
- ( model, Cmd.none )
-
- ActiveSchoolYearReceived result ->
- SchoolYear.handleActiveSchoolYearReceived result model
-
- UpdateNewSchoolYearName name ->
- let
- old =
- model.newSchoolYear
-
- new =
- { old | name = name }
- in
- ( { model | newSchoolYear = new }, Cmd.none )
-
- UpdateNewSchoolYearStart date ->
- let
- old =
- model.newSchoolYear
-
- new =
- { old | startDate = date }
- in
- ( { model | newSchoolYear = new }, Cmd.none )
-
- UpdateNewSchoolYearEnd date ->
- let
- old =
- model.newSchoolYear
-
- new =
- { old | endDate = date }
- in
- ( { model | newSchoolYear = new }, Cmd.none )
-
- CreateSchoolYear ->
- SchoolYear.handleCreateSchoolYear model
-
- SchoolYearCreated result ->
- SchoolYear.handleSchoolYearCreated result model
-
- ActivateSchoolYear id ->
- SchoolYear.handleActivateSchoolYear id model
-
- SchoolYearActivated result ->
- SchoolYear.handleSchoolYearActivated result model
-
- DeleteSchoolYear id ->
- SchoolYear.handleDeleteSchoolYear id model
-
- SchoolYearDeleted result ->
- SchoolYear.handleSchoolYearDeleted result model
-
- -- PDF Download
- DownloadYearlySummaryPDF ->
- case model.token of
- Just token ->
- ( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token )
-
- Nothing ->
- ( model, Cmd.none )
-
- YearlySummaryPDFReceived result ->
- case result of
- Ok pdfBytes ->
- let
- filename =
- "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf"
- in
- ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
-
- Err err ->
- ( { model | isProcessing = False }, Cmd.none )
-
- -- Delete Confirmation
- ConfirmDeleteUser userId ->
- ( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" )
-
- DeleteConfirmed confirmed ->
- if confirmed then
- case ( model.token, model.pendingDeleteId ) of
- ( Just token, Just id ) ->
- let
- isTimeEntry =
- List.any (\e -> e.id == id) model.timeEntries
- in
- if isTimeEntry then
- ( model, Api.TimeEntry.deleteTimeEntry token id )
-
- else
- ( model, Api.User.deleteUser token id )
-
- _ ->
- ( model, Cmd.none )
-
- else
- ( { model | pendingDeleteId = Nothing }, Cmd.none )
-
- -- Toasts
- ShowToast message toastType ->
- let
- newToast =
- { id = model.nextToastId
- , message = message
- , toastType = toastType
- , dismissible = True
- }
-
- dismissDelay =
- case toastType of
- ErrorToast ->
- 8000
-
- SuccessToast ->
- 5000
-
- InfoToast ->
- 5000
-
- WarningToast ->
- 6000
- in
- ( { model
- | toasts = model.toasts ++ [ newToast ]
- , nextToastId = model.nextToastId + 1
- }
- , Task.perform (\_ -> AutoDismissToast newToast.id)
- (Process.sleep dismissDelay)
- )
-
- DismissToast toastId ->
- ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
- , Cmd.none
- )
-
- AutoDismissToast toastId ->
- ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
- , Cmd.none
- )
diff --git a/frontend/src/Update/UserUpdate.elm b/frontend/src/Update/UserUpdate.elm
deleted file mode 100644
index 9fd4b85..0000000
--- a/frontend/src/Update/UserUpdate.elm
+++ /dev/null
@@ -1,196 +0,0 @@
-module Update.UserUpdate exposing
- ( handleCreateUser
- , handleDeleteUser
- , handleEditUserWorkHours
- , handleResetPasswordSaved
- , handleResetUserPassword
- , handleSaveResetPassword
- , handleSaveUserWorkHours
- , handleUserCreated
- , handleUserDeleted
- , handleUserWorkHoursSaved
- , handleUsersReceived
- )
-
-import Api.User
-import Http
-import Task
-import Types.Model exposing (Model, NewUser, ToastType(..), User)
-import Types.Msg exposing (Msg(..))
-
-
-handleCreateUser : Model -> ( Model, Cmd Msg )
-handleCreateUser model =
- case model.token of
- Just token ->
- ( model, Api.User.createUser token model.newUser )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleUserCreated result model =
- case result of
- Ok _ ->
- let
- emptyUser =
- NewUser "" "" False
- in
- case model.token of
- Just token ->
- ( { model | newUser = emptyUser }
- , Cmd.batch
- [ Api.User.fetchUsers token
- , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleDeleteUser : Int -> Model -> ( Model, Cmd Msg )
-handleDeleteUser userId model =
- case model.token of
- Just token ->
- ( model, Api.User.deleteUser token userId )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleUserDeleted result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | pendingDeleteId = Nothing
- , error = Nothing
- , editingUserId = Nothing
- , resetPasswordUserId = Nothing
- }
- , Cmd.batch
- [ Api.User.fetchUsers token
- , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( { model | pendingDeleteId = Nothing }, Cmd.none )
-
-
-handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg )
-handleUsersReceived result model =
- case result of
- Ok users ->
- ( { model | users = users }, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg )
-handleEditUserWorkHours userId model =
- case List.filter (\u -> u.id == userId) model.users |> List.head of
- Just user ->
- ( { model
- | editingUserId = Just userId
- , editingUserWorkHours = String.fromFloat user.yearlyWorkHours
- }
- , Cmd.none
- )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleSaveUserWorkHours : Model -> ( Model, Cmd Msg )
-handleSaveUserWorkHours model =
- case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of
- ( Just token, Just userId, Just hours ) ->
- ( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) )
-
- _ ->
- ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) )
-
-
-handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleUserWorkHoursSaved result model =
- case result of
- Ok _ ->
- case model.token of
- Just token ->
- ( { model
- | editingUserWorkHours = ""
- , editingUserId = Nothing
- , error = Nothing
- }
- , Cmd.batch
- [ Api.User.fetchUsers token
- , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
- ]
- )
-
- Nothing ->
- ( model, Cmd.none )
-
- Err err ->
- ( model, Cmd.none )
-
-
-handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg )
-handleResetUserPassword userId model =
- ( { model
- | resetPasswordUserId = Just userId
- , resetPasswordNew = ""
- }
- , Cmd.none
- )
-
-
-handleSaveResetPassword : Model -> ( Model, Cmd Msg )
-handleSaveResetPassword model =
- case model.resetPasswordUserId of
- Just userId ->
- case model.token of
- Just token ->
- ( model, Api.User.resetUserPassword token userId model.resetPasswordNew )
-
- Nothing ->
- ( model, Cmd.none )
-
- Nothing ->
- ( model, Cmd.none )
-
-
-handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
-handleResetPasswordSaved result model =
- case result of
- Ok _ ->
- ( { model
- | resetPasswordUserId = Nothing
- , resetPasswordNew = ""
- , error = Nothing
- }
- , Cmd.batch
- [ case model.token of
- Just token ->
- Api.User.fetchUsers token
-
- Nothing ->
- Cmd.none
- , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ())
- ]
- )
-
- Err err ->
- ( model, Cmd.none )
diff --git a/frontend/src/Utils/DateUtils.elm b/frontend/src/Utils/DateUtils.elm
deleted file mode 100644
index 1ea98dd..0000000
--- a/frontend/src/Utils/DateUtils.elm
+++ /dev/null
@@ -1,338 +0,0 @@
-module Utils.DateUtils exposing
- ( addDaysToDate
- , getDateForWeekDay
- , getDayOfWeek
- , getDayOfYear
- , getISOWeek
- , getISOWeekFromPosix
- , getWeekDateRange
- , getYearWeekFromDate
- , isLeapYear
- , monthToInt
- , nextWeek
- , previousWeek
- )
-
-import Time
-
-
-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
-
- jan4DayOfWeek =
- getDayOfWeek year 1 4
-
- mondayOfWeek1DayOfYear =
- 4 - jan4DayOfWeek
-
- weekNum =
- ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1
- in
- if weekNum < 1 then
- 52
-
- else if weekNum > 52 then
- let
- dec31DayOfWeek =
- getDayOfWeek year 12 31
-
- jan1DayOfWeek =
- getDayOfWeek year 1 1
- in
- if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then
- weekNum
-
- else
- 1
-
- else
- weekNum
-
-
-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
-
- mondayOfWeek1Date =
- 4 - jan4DayOfWeek
-
- targetDayOfYear =
- mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek
-
- ( finalYear, finalMonth, finalDay ) =
- if targetDayOfYear < 1 then
- addDaysToDate (year - 1) 12 31 targetDayOfYear
-
- else
- addDaysToDate year 1 targetDayOfYear 0
- 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 startYear startMonth startDay 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 if remaining > 0 then
- let
- daysInCurrentMonth =
- daysInMonth m y
-
- daysLeftInMonth =
- daysInCurrentMonth - d
- in
- if remaining <= daysLeftInMonth then
- ( y, m, d + remaining )
-
- else if m == 12 then
- helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1)
-
- else
- helper y (m + 1) 1 (remaining - daysLeftInMonth - 1)
-
- else if d + remaining >= 1 then
- ( y, m, d + remaining )
-
- else if m == 1 then
- let
- prevMonthDays =
- daysInMonth 12 (y - 1)
- in
- helper (y - 1) 12 prevMonthDays (remaining + d)
-
- else
- let
- prevMonthDays =
- daysInMonth (m - 1) y
- in
- helper y (m - 1) prevMonthDays (remaining + d)
- in
- helper startYear startMonth startDay 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
-
-
-getYearWeekFromDate : String -> ( Int, Int )
-getYearWeekFromDate dateStr =
- let
- parts =
- String.split "-" dateStr
-
- year =
- parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
-
- month =
- parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
-
- day =
- parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
- in
- ( year, getISOWeek year month day )
diff --git a/frontend/src/Utils/ErrorHandler.elm b/frontend/src/Utils/ErrorHandler.elm
deleted file mode 100644
index a9746e2..0000000
--- a/frontend/src/Utils/ErrorHandler.elm
+++ /dev/null
@@ -1,42 +0,0 @@
-module Utils.ErrorHandler exposing (handleApiError)
-
-import Api.Decoders exposing (apiErrorDecoder)
-import Http
-import Json.Decode as Decode
-import Task
-import Types.Model exposing (ToastType(..))
-import Types.Msg exposing (Msg(..))
-
-
-handleApiError : Http.Error -> Cmd Msg
-handleApiError error =
- let
- message =
- case error of
- Http.BadBody body ->
- case Decode.decodeString apiErrorDecoder body of
- Ok apiErr ->
- apiErr.message
-
- Err _ ->
- "Ein Fehler ist aufgetreten"
-
- Http.BadStatus 401 ->
- "Keine Berechtigung - bitte erneut anmelden"
-
- Http.BadStatus 403 ->
- "Zugriff verweigert"
-
- Http.BadStatus 404 ->
- "Ressource nicht gefunden"
-
- Http.Timeout ->
- "Zeitüberschreitung - bitte erneut versuchen"
-
- Http.NetworkError ->
- "Netzwerkfehler - bitte Verbindung prüfen"
-
- _ ->
- "Ein unerwarteter Fehler ist aufgetreten"
- in
- Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ())
diff --git a/frontend/src/Utils/Ports.elm b/frontend/src/Utils/Ports.elm
deleted file mode 100644
index f5b8dc2..0000000
--- a/frontend/src/Utils/Ports.elm
+++ /dev/null
@@ -1,20 +0,0 @@
-port module Utils.Ports exposing
- ( confirmDelete
- , confirmDeleteResponse
- , removeToken
- , saveToken
- )
-
-import Json.Encode as Encode
-
-
-port saveToken : Encode.Value -> Cmd msg
-
-
-port removeToken : () -> Cmd msg
-
-
-port confirmDelete : String -> Cmd msg
-
-
-port confirmDeleteResponse : (Bool -> msg) -> Sub msg
diff --git a/frontend/src/Utils/TimeUtils.elm b/frontend/src/Utils/TimeUtils.elm
deleted file mode 100644
index 2d74958..0000000
--- a/frontend/src/Utils/TimeUtils.elm
+++ /dev/null
@@ -1,34 +0,0 @@
-module Utils.TimeUtils exposing (calculateHours)
-
-
-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 if endTime == "manual" then
- case String.toFloat startTime of
- Just time ->
- time
-
- Nothing ->
- 0
-
- else
- 0
diff --git a/frontend/src/View/AdminDashboard.elm b/frontend/src/View/AdminDashboard.elm
deleted file mode 100644
index 9afcfb5..0000000
--- a/frontend/src/View/AdminDashboard.elm
+++ /dev/null
@@ -1,1165 +0,0 @@
-module View.AdminDashboard exposing (viewAdminDashboard)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Types.Model exposing (Model, Schedule, SchoolYear, TimeEntry, User, WeeklyHours, YearlyHoursSummary)
-import Types.Msg exposing (Msg(..))
-import Types.Page exposing (AdminTab(..))
-import Utils.DateUtils exposing (getYearWeekFromDate)
-import Utils.TimeUtils exposing (calculateHours)
-import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
-import View.Components.Schedule exposing (viewScheduleItemWithDay)
-
-
-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" ] ]
- , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ]
- [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ]
- ]
- ]
- , case model.activeTab of
- ScheduleTab ->
- viewScheduleTab model
-
- UsersTab ->
- viewUsersTab model
-
- TimeEntriesTab ->
- viewTimeEntriesTab model
-
- SchoolYearsTab ->
- viewSchoolYearsTab 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 []
- [ h2 [ class "title" ] [ text "Jahresübersicht" ]
- , viewYearlyHoursSummary model
- , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ]
- , viewAdminManualEntryForm model
- , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ]
- , case model.editingTimeEntryId of
- Just _ ->
- viewTimeEntriesEditForm model
-
- Nothing ->
- viewTimeEntriesListWithEdit model
- ]
-
-
-viewSchoolYearsTab : Model -> Html Msg
-viewSchoolYearsTab model =
- div []
- [ h2 [ class "title" ] [ text "Schuljahre verwalten" ]
- , case model.activeSchoolYear of
- Just schoolYear ->
- div [ class "notification is-info is-light mb-4" ]
- [ p [ class "has-text-weight-bold" ]
- [ text ("Aktives Schuljahr: " ++ schoolYear.name) ]
- , p [ class "is-size-7" ]
- [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ]
- ]
-
- Nothing ->
- div [ class "notification is-warning is-light mb-4" ]
- [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ]
- , viewSchoolYearForm model
- , viewSchoolYearsList model
- ]
-
-
-viewSchoolYearForm : Model -> Html Msg
-viewSchoolYearForm model =
- div [ class "box" ]
- [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ]
- , div [ class "columns" ]
- [ div [ class "column is-4" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "text"
- , placeholder "2024/2025"
- , value model.newSchoolYear.name
- , onInput UpdateNewSchoolYearName
- , disabled model.isProcessing
- ]
- []
- ]
- ]
- ]
- , div [ class "column is-4" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Startdatum" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "date"
- , value model.newSchoolYear.startDate
- , onInput UpdateNewSchoolYearStart
- , disabled model.isProcessing
- ]
- []
- ]
- ]
- ]
- , div [ class "column is-4" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Enddatum" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "date"
- , value model.newSchoolYear.endDate
- , onInput UpdateNewSchoolYearEnd
- , disabled model.isProcessing
- ]
- []
- ]
- ]
- ]
- ]
- , div [ class "field" ]
- [ div [ class "control" ]
- [ button
- [ class "button is-primary"
- , onClick CreateSchoolYear
- , disabled
- (String.isEmpty model.newSchoolYear.name
- || String.isEmpty model.newSchoolYear.startDate
- || String.isEmpty model.newSchoolYear.endDate
- || model.isProcessing
- )
- ]
- [ if model.isProcessing then
- span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
-
- else
- text ""
- , text " Schuljahr erstellen"
- ]
- ]
- ]
- ]
-
-
-viewSchoolYearsList : Model -> Html Msg
-viewSchoolYearsList model =
- div [ class "box mt-4" ]
- [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ]
- , if List.isEmpty model.schoolYears then
- p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ]
-
- else
- table [ class "table is-fullwidth is-striped is-hoverable" ]
- [ thead []
- [ tr []
- [ th [] [ text "Name" ]
- , th [] [ text "Startdatum" ]
- , th [] [ text "Enddatum" ]
- , th [ class "has-text-centered" ] [ text "Status" ]
- , th [ class "has-text-centered" ] [ text "Aktionen" ]
- ]
- ]
- , tbody []
- (List.map viewSchoolYearRow model.schoolYears)
- ]
- ]
-
-
-viewSchoolYearRow : SchoolYear -> Html Msg
-viewSchoolYearRow schoolYear =
- tr []
- [ td [] [ text schoolYear.name ]
- , td [] [ text schoolYear.startDate ]
- , td [] [ text schoolYear.endDate ]
- , td [ class "has-text-centered" ]
- [ if schoolYear.isActive then
- span [ class "tag is-success" ] [ text "Aktiv" ]
-
- else
- span [ class "tag is-light" ] [ text "Inaktiv" ]
- ]
- , td [ class "has-text-centered" ]
- [ if not schoolYear.isActive then
- button
- [ class "button is-small is-info mr-2"
- , onClick (ActivateSchoolYear schoolYear.id)
- ]
- [ text "Aktivieren" ]
-
- else
- text ""
- , button
- [ class "button is-small is-danger"
- , onClick (DeleteSchoolYear schoolYear.id)
- ]
- [ text "Löschen" ]
- ]
- ]
-
-
-viewScheduleList : Model -> Html Msg
-viewScheduleList model =
- div [ class "box" ]
- [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ]
- , table [ class "table is-fullwidth is-striped" ]
- [ thead []
- [ tr []
- [ th [] [ text "Tag" ]
- , th [] [ text "Zeit" ]
- , th [] [ text "Typ" ]
- , th [] [ text "Titel" ]
- , th [] [ text "Aktion" ]
- ]
- ]
- , tbody []
- (List.map viewScheduleRow model.schedules)
- ]
- ]
-
-
-viewScheduleForm : Model -> Html Msg
-viewScheduleForm model =
- div [ class "box" ]
- [ div [ class "columns" ]
- [ div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Wochentag" ]
- , div [ class "control" ]
- [ div [ class "select is-fullwidth" ]
- [ select
- [ onInput UpdateNewScheduleDay
- , disabled model.isProcessing
- , value model.newSchedule.dayOfWeek
- ]
- [ option [ value "" ] [ text "Wochentag wählen" ]
- , option [ value "0" ] [ text "Montag" ]
- , option [ value "1" ] [ text "Dienstag" ]
- , option [ value "2" ] [ text "Mittwoch" ]
- , option [ value "3" ] [ text "Donnerstag" ]
- , option [ value "4" ] [ text "Freitag" ]
- ]
- ]
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Startzeit" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "time"
- , value model.newSchedule.startTime
- , onInput UpdateNewScheduleStart
- , disabled model.isProcessing
- ]
- []
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Endzeit" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "time"
- , value model.newSchedule.endTime
- , onInput UpdateNewScheduleEnd
- , disabled model.isProcessing
- ]
- []
- ]
- ]
- ]
- ]
- , div [ class "columns" ]
- [ div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Typ" ]
- , div [ class "control" ]
- [ div [ class "select is-fullwidth" ]
- [ select
- [ onInput UpdateNewScheduleType
- , value model.newSchedule.scheduleType
- , disabled model.isProcessing
- ]
- [ option [ value "lesson" ] [ text "Unterricht" ]
- , option [ value "break" ] [ text "Pause" ]
- ]
- ]
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Titel" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "text"
- , placeholder "z.B. Mathematik"
- , value model.newSchedule.title
- , onInput UpdateNewScheduleTitle
- , disabled model.isProcessing
- ]
- []
- ]
- ]
- ]
- ]
- , div [ class "field" ]
- [ div [ class "control" ]
- [ 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 ""
- ]
-
-
-viewScheduleRow : Schedule -> Html Msg
-viewScheduleRow schedule =
- let
- dayName =
- case schedule.dayOfWeek of
- 0 ->
- "Montag"
-
- 1 ->
- "Dienstag"
-
- 2 ->
- "Mittwoch"
-
- 3 ->
- "Donnerstag"
-
- 4 ->
- "Freitag"
-
- _ ->
- "Unbekannt"
-
- typeName =
- if schedule.scheduleType == "break" then
- "Pause"
-
- else
- "Unterricht"
- in
- tr []
- [ td [] [ text dayName ]
- , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
- , td [] [ text typeName ]
- , td [] [ text schedule.title ]
- , td []
- [ button
- [ class "button is-small is-danger"
- , onClick (DeleteSchedule schedule.id)
- ]
- [ text "Löschen" ]
- ]
- ]
-
-
-viewUserForm : Model -> Html Msg
-viewUserForm model =
- div [ class "box" ]
- [ div [ class "columns" ]
- [ div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Benutzername" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "text"
- , placeholder "Benutzername"
- , value model.newUser.username
- , onInput UpdateNewUsername
- ]
- []
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Passwort" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "password"
- , placeholder "Passwort"
- , value model.newUser.password
- , onInput UpdateNewPassword
- ]
- []
- ]
- ]
- ]
- , div [ class "column is-narrow" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Admin" ]
- , div [ class "control" ]
- [ label [ class "checkbox" ]
- [ input
- [ type_ "checkbox"
- , checked model.newUser.isAdmin
- , onCheck UpdateNewUserAdmin
- ]
- []
- , text " Admin-Rechte"
- ]
- ]
- ]
- ]
- ]
- , div [ class "field" ]
- [ div [ class "control" ]
- [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ]
- ]
- ]
- ]
-
-
-viewUserList : Model -> Html Msg
-viewUserList model =
- div [ class "box" ]
- [ h3 [ class "subtitle" ] [ text "Benutzer" ]
- , if List.isEmpty model.users then
- p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ]
-
- else
- table [ class "table is-fullwidth is-striped is-hoverable" ]
- [ thead []
- [ tr []
- [ th [] [ text "ID" ]
- , th [] [ text "Benutzername" ]
- , th [] [ text "Rolle" ]
- , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ]
- , th [ class "has-text-centered" ] [ text "Aktionen" ]
- ]
- ]
- , tbody []
- (List.map (viewUserRowWithActions model) model.users)
- ]
- ]
-
-
-viewUserRowWithActions : Model -> User -> Html Msg
-viewUserRowWithActions model user =
- if model.editingUserId == Just user.id then
- tr []
- [ td [] [ text (String.fromInt user.id) ]
- , td [] [ text user.username ]
- , td []
- [ text
- (if user.isAdmin then
- "Admin"
-
- else
- "Benutzer"
- )
- ]
- , td []
- [ input
- [ class "input is-small"
- , type_ "number"
- , step "0.5"
- , value model.editingUserWorkHours
- , onInput UpdateEditUserWorkHours
- ]
- []
- ]
- , td [ class "has-text-centered" ]
- [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ]
- , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ]
- ]
- ]
-
- else if model.resetPasswordUserId == Just user.id then
- tr []
- [ td [] [ text (String.fromInt user.id) ]
- , td [] [ text user.username ]
- , td []
- [ text
- (if user.isAdmin then
- "Admin"
-
- else
- "Benutzer"
- )
- ]
- , td []
- [ input
- [ class "input is-small"
- , type_ "password"
- , placeholder "Neues Passwort"
- , value model.resetPasswordNew
- , onInput UpdateResetPasswordNew
- ]
- []
- ]
- , td [ class "has-text-centered" ]
- [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ]
- , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ]
- ]
- ]
-
- else
- tr []
- [ td [] [ text (String.fromInt user.id) ]
- , td [] [ text user.username ]
- , td []
- [ text
- (if user.isAdmin then
- "Admin"
-
- else
- "Benutzer"
- )
- ]
- , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ]
- , td [ class "has-text-centered" ]
- [ if user.id == 1 then
- span [ class "tag is-light" ] [ text "Geschützt" ]
-
- else
- div []
- [ button
- [ class "button is-small is-info mr-2"
- , onClick (EditUserWorkHours user.id)
- ]
- [ text "Arbeitszeit" ]
- , button
- [ class "button is-small is-warning mr-2"
- , onClick (ResetUserPassword user.id)
- ]
- [ text "PW Reset" ]
- , button
- [ class "button is-small is-danger"
- , onClick (DeleteUser user.id)
- ]
- [ text "Löschen" ]
- ]
- ]
- ]
-
-
-viewUserRow : User -> Html Msg
-viewUserRow user =
- tr []
- [ td [] [ text (String.fromInt user.id) ]
- , td [] [ text user.username ]
- , td []
- [ text
- (if user.isAdmin then
- "Admin"
-
- else
- "Benutzer"
- )
- ]
- , 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" ]
- ]
- ]
-
-
-viewTimeEntriesList : Model -> Html Msg
-viewTimeEntriesList model =
- let
- filteredEntries =
- List.filter
- (\e ->
- let
- ( entryYear, entryWeek ) =
- getYearWeekFromDate e.date
- in
- entryWeek == model.currentWeek && entryYear == model.currentYear
- )
- model.timeEntries
- in
- div [ class "box" ]
- [ 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 (viewTimeEntryRowWithActions model) filteredEntries)
- ]
- ]
-
-
-viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg
-viewTimeEntryRowWithActions model entry =
- let
- hours =
- if entry.entryType == "lesson" then
- 1.0
-
- else
- calculateHours entry.startTime entry.endTime
- in
- tr []
- [ 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.") ]
- , td []
- [ div [ class "buttons are-small" ]
- [ button
- [ class "button is-info is-small"
- , onClick (StartEditingTimeEntry entry.id entry)
- ]
- [ text "Bearbeiten" ]
- , button
- [ class "button is-danger is-small"
- , onClick (ConfirmDeleteTimeEntry entry.id)
- ]
- [ text "Löschen" ]
- ]
- ]
- ]
-
-
-viewTimeEntriesEditForm : Model -> Html Msg
-viewTimeEntriesEditForm model =
- div [ class "box has-background-warning-light" ]
- [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ]
- , div [ class "columns" ]
- [ div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Datum" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "date"
- , value model.editingTimeEntry.date
- , onInput UpdateEditTimeEntryDate
- ]
- []
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Startzeit" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "time"
- , value model.editingTimeEntry.startTime
- , onInput UpdateEditTimeEntryStartTime
- ]
- []
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Endzeit" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "time"
- , value model.editingTimeEntry.endTime
- , onInput UpdateEditTimeEntryEndTime
- ]
- []
- ]
- ]
- ]
- , div [ class "column" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Typ" ]
- , div [ class "control" ]
- [ div [ class "select is-fullwidth" ]
- [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ]
- [ option [ value "lesson" ] [ text "Unterricht" ]
- , option [ value "break" ] [ text "Pause" ]
- ]
- ]
- ]
- ]
- ]
- ]
- , div [ class "field is-grouped mt-4" ]
- [ div [ class "control" ]
- [ button
- [ class "button is-success"
- , onClick SaveEditTimeEntry
- ]
- [ text "Speichern" ]
- ]
- , div [ class "control" ]
- [ button
- [ class "button is-light"
- , onClick CancelEditTimeEntry
- ]
- [ text "Abbrechen" ]
- ]
- ]
- , viewTimeEntriesListWithEdit model
- ]
-
-
-viewTimeEntriesListWithEdit : Model -> Html Msg
-viewTimeEntriesListWithEdit model =
- div [ class "box" ]
- [ if List.isEmpty model.timeEntries then
- p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ]
-
- else
- table [ class "table is-fullwidth is-striped is-hoverable" ]
- [ thead []
- [ tr []
- [ th [] [ text "Mitarbeiter" ]
- , th [] [ text "Datum" ]
- , th [] [ text "Zeit" ]
- , th [] [ text "Typ" ]
- , th [ class "has-text-right" ] [ text "Stunden" ]
- , th [ class "has-text-centered" ] [ text "Aktionen" ]
- ]
- ]
- , tbody []
- (List.map (viewTimeEntryRowWithEdit model) model.timeEntries)
- ]
- ]
-
-
-viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg
-viewTimeEntryRowWithEdit model entry =
- let
- hours =
- calculateHours entry.startTime entry.endTime
-
- isEditing =
- model.editingTimeEntryId == Just entry.id
- in
- if isEditing then
- tr []
- [ td [] [ text entry.username ]
- , td []
- [ input
- [ class "input is-small"
- , type_ "date"
- , value model.editingTimeEntry.date
- , onInput UpdateEditTimeEntryDate
- ]
- []
- ]
- , td []
- [ div [ class "field is-grouped" ]
- [ div [ class "control" ]
- [ input
- [ class "input is-small"
- , type_ "time"
- , value model.editingTimeEntry.startTime
- , onInput UpdateEditTimeEntryStartTime
- ]
- []
- ]
- , div [ class "control" ]
- [ input
- [ class "input is-small"
- , type_ "time"
- , value model.editingTimeEntry.endTime
- , onInput UpdateEditTimeEntryEndTime
- ]
- []
- ]
- ]
- ]
- , td []
- [ div [ class "select is-small" ]
- [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ]
- [ option [ value "lesson" ] [ text "Unterricht" ]
- , option [ value "break" ] [ text "Pause" ]
- ]
- ]
- ]
- , td [ class "has-text-right" ] [ text "" ]
- , td [ class "has-text-centered" ]
- [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ]
- , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ]
- ]
- ]
-
- else
- tr []
- [ 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.") ]
- , td [ class "has-text-centered" ]
- [ button
- [ class "button is-small is-info mr-2"
- , onClick (EditTimeEntry entry.id)
- ]
- [ text "Bearbeiten" ]
- , button
- [ class "button is-small is-danger"
- , onClick (ConfirmDeleteTimeEntry entry.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 "Arbeitet" ]
- , th [ class "has-text-right" ] [ text "Soll" ]
- , th [ class "has-text-right" ] [ text "Verbleibend" ]
- , th [] [ text "Fortschritt" ]
- ]
- ]
- , 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.") ]
- , th [ class "has-text-right has-text-weight-bold" ]
- [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ]
- , th [] [ text "" ]
- , th [] [ text "" ]
- ]
- ]
- ]
- ]
-
-
-viewWeeklyHoursRow : WeeklyHours -> Html Msg
-viewWeeklyHoursRow hours =
- let
- progressPercent =
- Basics.min 100 (hours.totalHours / hours.targetHours * 100)
-
- progressColor =
- if hours.totalHours >= hours.targetHours then
- "is-success"
-
- else if hours.totalHours >= hours.targetHours * 0.8 then
- "is-info"
-
- else
- "is-warning"
- in
- tr []
- [ td [] [ text hours.username ]
- , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ]
- , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ]
- , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ]
- , td []
- [ progress
- [ class ("progress " ++ progressColor)
- , value (String.fromFloat progressPercent)
- , Html.Attributes.max "100"
- ]
- []
- ]
- ]
-
-
-viewAdminManualEntryForm : Model -> Html Msg
-viewAdminManualEntryForm model =
- div [ class "box has-background-info-light" ]
- [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ]
- , p [ class "help mb-3" ]
- [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ]
- , div [ class "columns" ]
- [ div [ class "column is-4" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Mitarbeiter" ]
- , div [ class "control" ]
- [ div [ class "select is-fullwidth" ]
- [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ]
- (option [ value "" ] [ text "-- Wählen --" ]
- :: List.map
- (\u ->
- option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ]
- )
- model.users
- )
- ]
- ]
- ]
- ]
- , div [ class "column is-4" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Datum" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "date"
- , value model.adminManualEntryForm.date
- , onInput UpdateManualEntryDate
- ]
- []
- ]
- ]
- ]
- , div [ class "column is-4" ]
- [ div [ class "field" ]
- [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "number"
- , step "0.5"
- , placeholder "z.B. 2.5 oder -1.0"
- , value model.adminManualEntryForm.hours
- , onInput UpdateManualEntryHours
- ]
- []
- ]
- , p [ class "help" ]
- [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ]
- ]
- ]
- ]
- , div [ class "field is-grouped mt-4" ]
- [ div [ class "control" ]
- [ button
- [ class "button is-info"
- , onClick SaveAdminTimeEntry
- , disabled
- (case model.adminManualEntryForm.selectedUserId of
- Just _ ->
- model.isProcessing || String.isEmpty model.adminManualEntryForm.hours
-
- Nothing ->
- True
- )
- ]
- [ text "Eintrag erstellen" ]
- ]
- ]
- ]
-
-
-viewYearlyHoursSummary : Model -> Html Msg
-viewYearlyHoursSummary model =
- div [ class "box" ]
- [ div [ class "level mb-4" ]
- [ div [ class "level-left" ]
- [ div [ class "level-item" ]
- [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ]
- ]
- ]
- , div [ class "level-right" ]
- [ div [ class "level-item" ]
- [ a
- [ class "button is-info"
- , onClick DownloadYearlySummaryPDF
- , disabled model.isProcessing
- ]
- [ span [ class "icon" ]
- [ i [ class "fas fa-file-pdf" ] [] ]
- , span []
- [ text
- (if model.isProcessing then
- "Wird erstellt..."
-
- else
- "PDF exportieren"
- )
- ]
- ]
- ]
- ]
- ]
- , if List.isEmpty model.yearlyHoursSummary then
- p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ]
-
- else
- table [ class "table is-fullwidth is-striped is-hoverable" ]
- [ thead []
- [ tr []
- [ th [] [ text "Mitarbeiter" ]
- , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ]
- , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ]
- , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ]
- , th [ class "has-text-centered" ] [ text "Status" ]
- ]
- ]
- , tbody []
- (List.map viewYearlyHourRow model.yearlyHoursSummary)
- ]
- ]
-
-
-viewYearlyHourRow : YearlyHoursSummary -> Html Msg
-viewYearlyHourRow summary =
- let
- statusClass =
- if summary.remainingYearly > 0 then
- "has-text-danger"
-
- else if abs summary.remainingYearly < 0.5 then
- "has-text-success"
-
- else
- "has-text-warning"
- in
- tr []
- [ td [] [ text summary.username ]
- , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ]
- , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ]
- , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ]
- , td [ class ("has-text-centered " ++ statusClass) ]
- [ if summary.remainingYearly > 0 then
- text ("Offen: " ++ String.fromFloat summary.remainingYearly)
-
- else if summary.remainingYearly < -0.5 then
- text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly))
-
- else
- text "✓ Erfüllt"
- ]
- ]
diff --git a/frontend/src/View/Components/Navigation.elm b/frontend/src/View/Components/Navigation.elm
deleted file mode 100644
index ba3895d..0000000
--- a/frontend/src/View/Components/Navigation.elm
+++ /dev/null
@@ -1,99 +0,0 @@
-module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Types.Model exposing (Model, Schedule)
-import Types.Msg exposing (Msg(..))
-import View.Components.Schedule exposing (viewScheduleItemWithDay)
-
-
-viewWeekNavigation : Model -> Html Msg
-viewWeekNavigation model =
- let
- dateRange =
- case model.weekDates of
- Just wd ->
- wd.range
-
- Nothing ->
- "Laden..."
- in
- div [ class "box" ]
- [ nav [ class "level" ]
- [ div [ class "level-left" ]
- [ div [ class "level-item" ]
- [ button
- [ class "button is-primary"
- , onClick PreviousWeek
- ]
- [ span [ class "icon" ]
- [ i [ class "fas fa-chevron-left" ] [] ]
- , span [] [ text "Vorherige Woche" ]
- ]
- ]
- ]
- , div [ class "level-item" ]
- [ div
- [ style "display" "flex"
- , style "flex-direction" "column"
- , style "align-items" "center"
- , style "gap" "0.5rem"
- , style "min-width" "250px"
- ]
- [ p
- [ class "heading"
- , style "margin" "0"
- , style "line-height" "1.2"
- ]
- [ text "Kalenderwoche" ]
- , p
- [ class "title is-3"
- , style "margin" "0"
- , style "line-height" "1.2"
- ]
- [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ]
- , p
- [ class "subtitle is-6"
- , style "margin" "0"
- , style "line-height" "1.2"
- ]
- [ text dateRange ]
- ]
- ]
- , div [ class "level-right" ]
- [ div [ class "level-item" ]
- [ button
- [ class "button is-primary"
- , onClick NextWeek
- ]
- [ span [] [ text "Nächste Woche" ]
- , span [ class "icon" ]
- [ i [ class "fas fa-chevron-right" ] [] ]
- ]
- ]
- ]
- ]
- ]
-
-
-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)
- ]
diff --git a/frontend/src/View/Components/Schedule.elm b/frontend/src/View/Components/Schedule.elm
deleted file mode 100644
index 57730bb..0000000
--- a/frontend/src/View/Components/Schedule.elm
+++ /dev/null
@@ -1,76 +0,0 @@
-module View.Components.Schedule exposing (viewScheduleItemWithDay)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Types.Model exposing (Model, Schedule)
-import Types.Msg exposing (Msg(..))
-
-
-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) ]
- ]
diff --git a/frontend/src/View/Components/Toast.elm b/frontend/src/View/Components/Toast.elm
deleted file mode 100644
index e55d2fe..0000000
--- a/frontend/src/View/Components/Toast.elm
+++ /dev/null
@@ -1,66 +0,0 @@
-module View.Components.Toast exposing (viewToasts)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Types.Model exposing (Model, Schedule, Toast, ToastType(..))
-import Types.Msg exposing (Msg(..))
-import Utils.TimeUtils exposing (calculateHours)
-import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
-import View.Components.Schedule exposing (viewScheduleItemWithDay)
-
-
-viewToasts : List Toast -> Html Msg
-viewToasts toasts =
- div [ class "toast-container" ]
- (List.map viewToast toasts)
-
-
-viewToast : Toast -> Html Msg
-viewToast toast =
- let
- toastClass =
- case toast.toastType of
- ErrorToast ->
- "toast-error"
-
- SuccessToast ->
- "toast-success"
-
- InfoToast ->
- "toast-info"
-
- WarningToast ->
- "toast-warning"
-
- icon =
- case toast.toastType of
- ErrorToast ->
- "fas fa-exclamation-circle"
-
- SuccessToast ->
- "fas fa-check-circle"
-
- InfoToast ->
- "fas fa-info-circle"
-
- WarningToast ->
- "fas fa-exclamation-triangle"
- in
- div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ]
- [ div [ class "toast-content" ]
- [ span [ class "toast-icon" ]
- [ i [ class icon ] [] ]
- , span [ class "toast-message" ] [ text toast.message ]
- ]
- , if toast.dismissible then
- button
- [ class "toast-close"
- , onClick (DismissToast toast.id)
- , attribute "aria-label" "Schließen"
- ]
- [ i [ class "fas fa-times" ] [] ]
-
- else
- text ""
- ]
diff --git a/frontend/src/View/Login.elm b/frontend/src/View/Login.elm
deleted file mode 100644
index 9ed2485..0000000
--- a/frontend/src/View/Login.elm
+++ /dev/null
@@ -1,57 +0,0 @@
-module View.Login exposing (viewLogin)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Types.Model exposing (Model)
-import Types.Msg exposing (Msg(..))
-
-
-viewLogin : Model -> Html Msg
-viewLogin model =
- section [ class "section" ]
- [ div [ class "container" ]
- [ div [ class "columns is-centered" ]
- [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
- [ div [ class "box" ]
- [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ]
- , div [ class "field" ]
- [ label [ class "label" ] [ text "Benutzername" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "text"
- , placeholder "Benutzername"
- , value model.username
- , onInput UpdateUsername
- ]
- []
- ]
- ]
- , div [ class "field" ]
- [ label [ class "label" ] [ text "Passwort" ]
- , div [ class "control" ]
- [ input
- [ class "input"
- , type_ "password"
- , placeholder "Passwort"
- , value model.password
- , onInput UpdatePassword
- ]
- []
- ]
- ]
- , div [ class "field" ]
- [ div [ class "control" ]
- [ button
- [ class "button is-primary is-fullwidth"
- , onClick Login
- ]
- [ text "Anmelden" ]
- ]
- ]
- ]
- ]
- ]
- ]
- ]
diff --git a/frontend/src/View/UserDashboard.elm b/frontend/src/View/UserDashboard.elm
deleted file mode 100644
index 60fac13..0000000
--- a/frontend/src/View/UserDashboard.elm
+++ /dev/null
@@ -1,338 +0,0 @@
-module View.UserDashboard exposing (viewUserDashboard)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Types.Model exposing (Model, Schedule)
-import Types.Msg exposing (Msg(..))
-import Utils.TimeUtils exposing (calculateHours)
-import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
-import View.Components.Schedule exposing (viewScheduleItemWithDay)
-
-
-viewUserDashboard : Model -> Html Msg
-viewUserDashboard model =
- div []
- [ nav [ class "navbar is-primary" ]
- [ div [ class "navbar-brand" ]
- [ div [ class "navbar-item" ]
- [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
- ]
- , 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
- ""
- )
- )
- ]
- [ 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" ]
- [ viewWeekNavigation model
- , h2 [ class "title" ] [ text "Stundenplan" ]
- , if model.hasEntriesForCurrentWeek && not model.weekEditMode then
- div [ class "notification is-success" ]
- [ div [ class "level" ]
- [ div [ class "level-left" ]
- [ div [ class "level-item" ]
- [ span [ class "icon" ]
- [ i [ class "fas fa-check-circle" ] [] ]
- , span [] [ text "Diese Woche wurde bereits erfasst" ]
- ]
- ]
- , div [ class "level-right" ]
- [ div [ class "level-item" ]
- [ button
- [ class "button is-warning"
- , onClick EnableEditMode
- , disabled model.isProcessing
- ]
- [ text "Bearbeiten" ]
- ]
- ]
- ]
- ]
-
- else if model.weekEditMode then
- div [ class "notification is-warning" ]
- [ div [ class "level" ]
- [ div [ class "level-left" ]
- [ div [ class "level-item" ]
- [ span [ class "icon" ]
- [ i [ class "fas fa-edit" ] [] ]
- , span [] [ text "Bearbeitungsmodus aktiv" ]
- ]
- ]
- , div [ class "level-right" ]
- [ div [ class "level-item" ]
- [ 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"
- , onClick DisableEditMode
- ]
- [ text "Abbrechen" ]
- ]
- ]
- ]
- ]
-
- else
- div [ class "notification is-info is-light" ]
- [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ]
- , viewScheduleGridWithWeek model
- , if not model.hasEntriesForCurrentWeek || model.weekEditMode then
- div [ class "field mt-4" ]
- [ div [ class "control" ]
- [ button
- [ class "button is-primary is-large is-fullwidth"
- , onClick SaveTimeEntries
- , 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
- text ""
- , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ]
- , viewUserYearlyTotal 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 []
- [ 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)
- ]
-
-
-viewUserYearlyTotal : Model -> Html Msg
-viewUserYearlyTotal model =
- let
- yearlyTotal =
- model.timeEntries
- |> List.map
- (\entry ->
- if entry.entryType == "lesson" then
- 1.0
-
- else
- Utils.TimeUtils.calculateHours entry.startTime entry.endTime
- )
- |> List.sum
-
- userTarget =
- List.filter (\u -> not u.isAdmin) model.users
- |> List.head
- |> Maybe.map .yearlyWorkHours
- |> Maybe.withDefault 60
-
- remaining =
- userTarget - yearlyTotal
-
- progressPercent =
- Basics.min 100 (yearlyTotal / userTarget * 100)
-
- progressColor =
- if remaining <= 0 then
- "is-success"
-
- else if yearlyTotal >= userTarget * 0.8 then
- "is-info"
-
- else
- "is-warning"
- in
- div [ class "box" ]
- [ div [ class "columns" ]
- [ div [ class "column" ]
- [ p [ class "heading" ] [ text "Jahresenziel" ]
- , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ]
- ]
- , div [ class "column" ]
- [ p [ class "heading" ] [ text "Geleistete Stunden" ]
- , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ]
- ]
- , div [ class "column" ]
- [ p [ class "heading" ] [ text "Restliche Stunden" ]
- , p
- [ class
- ("title is-4 "
- ++ (if remaining <= 0 then
- "has-text-success"
-
- else
- "has-text-warning"
- )
- )
- ]
- [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ]
- ]
- ]
- , progress
- [ class ("progress " ++ progressColor)
- , value (String.fromFloat progressPercent)
- , Html.Attributes.max "100"
- ]
- [ text (String.fromFloat progressPercent ++ "%") ]
- ]
-
-
-viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg
-viewDayColumnWithWeek model ( 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
- 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)
- ]
-
-
-viewUserWeeklySummary : Model -> Html Msg
-viewUserWeeklySummary model =
- case model.userWeeklySummary of
- Just summary ->
- let
- progressPercent =
- Basics.min 100 (summary.totalHours / summary.targetHours * 100)
-
- progressColor =
- if summary.totalHours >= summary.targetHours then
- "is-success"
-
- else if summary.totalHours >= summary.targetHours * 0.8 then
- "is-info"
-
- else
- "is-warning"
- in
- div [ class "box" ]
- [ div [ class "columns" ]
- [ div [ class "column" ]
- [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ]
- , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ]
- , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ]
- ]
- , div [ class "column" ]
- [ p [ class "heading" ] [ text "Verbleibend" ]
- , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ]
- [ text (String.fromFloat summary.remainingHours ++ " Std.") ]
- , if summary.remainingHours < 0 then
- p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ]
-
- else
- p [ class "subtitle is-6" ] [ text "" ]
- ]
- ]
- , progress
- [ class ("progress " ++ progressColor)
- , value (String.fromFloat progressPercent)
- , Html.Attributes.max "100"
- ]
- [ text (String.fromFloat progressPercent ++ "%") ]
- ]
-
- Nothing ->
- div [ class "box" ]
- [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ]
- ]
diff --git a/frontend/src/View/View.elm b/frontend/src/View/View.elm
deleted file mode 100644
index c16d910..0000000
--- a/frontend/src/View/View.elm
+++ /dev/null
@@ -1,29 +0,0 @@
-module View.View exposing (view)
-
-import Html exposing (Html, div)
-import Html.Attributes exposing (class)
-import Types.Model exposing (Model)
-import Types.Msg exposing (Msg(..))
-import Types.Page exposing (Page(..))
-import View.AdminDashboard exposing (viewAdminDashboard)
-import View.Components.Toast exposing (viewToasts)
-import View.Login exposing (viewLogin)
-import View.UserDashboard exposing (viewUserDashboard)
-
-
-view : Model -> Html Msg
-view model =
- div [ class "app-container" ]
- [ viewToasts model.toasts
- , div [ class "container" ]
- [ case model.page of
- LoginPage ->
- viewLogin model
-
- UserDashboard ->
- viewUserDashboard model
-
- AdminDashboard ->
- viewAdminDashboard model
- ]
- ]
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 0000000..4c1b0c2
--- /dev/null
+++ b/frontend/src/app.css
@@ -0,0 +1,2 @@
+@import "tailwindcss";
+@plugin "daisyui";
diff --git a/frontend/src/assets/svelte.svg b/frontend/src/assets/svelte.svg
new file mode 100644
index 0000000..c5e0848
--- /dev/null
+++ b/frontend/src/assets/svelte.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/AdminDashboard.svelte b/frontend/src/components/AdminDashboard.svelte
new file mode 100644
index 0000000..82405cf
--- /dev/null
+++ b/frontend/src/components/AdminDashboard.svelte
@@ -0,0 +1,388 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{$auth.user?.username}
+
Administrator
+
+
+
+
+
+ {user?.username?.charAt(0).toUpperCase()}
+
+
+
+
+
+
+
+
+ {#if activeTab === "schedule"}
+
+ {:else if activeTab === "users"}
+
+ {:else if activeTab === "timeEntries"}
+
+ {:else if activeTab === "schoolYears"}
+
+ {:else if activeTab === "settings"}
+
+ {:else if activeTab === "substitutions"}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/Login.svelte b/frontend/src/components/Login.svelte
new file mode 100644
index 0000000..560ea4c
--- /dev/null
+++ b/frontend/src/components/Login.svelte
@@ -0,0 +1,130 @@
+
+
+
+
+
+

(e.target.style.display = "none")}
+ />
+
Zeiterfassung
+
+ Willkommen zurück. Bitte melden Sie sich an, um Ihre Arbeitszeiten zu
+ erfassen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ScheduleItem.svelte b/frontend/src/components/ScheduleItem.svelte
new file mode 100644
index 0000000..c25b7d8
--- /dev/null
+++ b/frontend/src/components/ScheduleItem.svelte
@@ -0,0 +1,49 @@
+
+
+ isClickable && dispatch("toggle")}
+ on:keydown={() => {}}
+ role="button"
+ tabindex="0"
+>
+
+
+ {schedule.startTime} - {schedule.endTime}
+
+
+ {schedule.title}
+
+ {#if schedule.scheduleType === "break"}
+
+ Pause
+
+ {/if}
+
+
diff --git a/frontend/src/components/ScheduleItems.svelte b/frontend/src/components/ScheduleItems.svelte
new file mode 100644
index 0000000..c95444d
--- /dev/null
+++ b/frontend/src/components/ScheduleItems.svelte
@@ -0,0 +1,42 @@
+
+
+ isClickable && dispatch("toggle")}
+ on:keydown={() => {}}
+ role="button"
+ tabindex="0"
+>
+
+ {schedule.startTime} - {schedule.endTime}
+
+
+ {schedule.title}
+ {schedule.scheduleType === "break" ? "(Pause)" : ""}
+
+
diff --git a/frontend/src/components/ToastNotification.svelte b/frontend/src/components/ToastNotification.svelte
new file mode 100644
index 0000000..0541026
--- /dev/null
+++ b/frontend/src/components/ToastNotification.svelte
@@ -0,0 +1,74 @@
+
+
+
+ {#each $toasts as toast (toast.id)}
+
+ {#if toast.type === "error"}
+
+ {:else if toast.type === "success"}
+
+ {:else}
+
+ {/if}
+
+
{toast.message}
+
+
+
+ {/each}
+
diff --git a/frontend/src/components/UserDashboard.svelte b/frontend/src/components/UserDashboard.svelte
new file mode 100644
index 0000000..4c7fca2
--- /dev/null
+++ b/frontend/src/components/UserDashboard.svelte
@@ -0,0 +1,900 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ - Mein Bereich
+ -
+ {activeView === "schedule" ? "Stundenplan" : "Vertretungsbörse"}
+
+
+
+
+ {activeView === "schedule" ? `KW ${currentWeek}` : "Vertretungen"}
+
+
+
+
+
+
{$auth.user?.username}
+
Benutzer
+
+
+
+
+ {$auth.user?.username?.charAt(0).toUpperCase()}
+
+
+
+
+
+
+
+
+ {#if activeView === "schedule"}
+
+
+
+
+
+ Kalenderwoche
+
+
+ KW {currentWeek} /
+ {currentISOYear}
+
+
+ {weekDates[0]?.date} — {weekDates[4]?.date}
+
+
+
+
+
+
+
+
+
+
Geleistet
+
{yearlyTotal.toFixed(1)}
+
+
+
+
+
Offen
+
+ {Math.max(0, remaining).toFixed(1)}
+
+
+
+
+
+ {#if isLoadingData}
+
+
+ {#each Array(5) as _}
+
+ {/each}
+
+
+
+ {#each Array(5) as _}
+
+ {/each}
+
+ {:else}
+ {#if hasEntriesForWeek && !weekEditMode}
+
+
+
+
Erfasst!
+
Stunden gespeichert.
+
+
+
+ {:else if weekEditMode}
+
+
+
+
Bearbeitungsmodus
+
Speichern nicht vergessen!
+
+
+
+
+
+
+ {:else}
+
+
+
+
Zeiterfassung
+
Wählen Sie Ihre Stunden.
+
+
+ {/if}
+
+
+
+
+
+ {#each weekDates as day}
+ |
+ {day.name}
+
+ {day.date}
+
+ |
+ {/each}
+
+
+
+
+ {#each weekDates as day}
+ |
+
+ {#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
+
+ e.scheduleId === schedule.id &&
+ e.dayOfWeek === day.dayIndex,
+ )}
+ isClickable={(!hasEntriesForWeek || weekEditMode) &&
+ !processing}
+ on:toggle={() =>
+ toggleSelection(schedule.id, day.dayIndex)}
+ />
+ {/each}
+
+ |
+ {/each}
+
+
+
+
+
+
+ {#each weekDates as day}
+
+
+
+ {day.name}
+ {day.date}
+
+
+
+ {#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
+
+ e.scheduleId === schedule.id &&
+ e.dayOfWeek === day.dayIndex,
+ )}
+ isClickable={(!hasEntriesForWeek || weekEditMode) &&
+ !processing}
+ on:toggle={() =>
+ toggleSelection(schedule.id, day.dayIndex)}
+ />
+ {/each}
+
+
+
+ {/each}
+
+ {/if}
+ {:else if activeView === "market"}
+
+
Offene Vertretungen
+
+
+
+ {#if openSubstitutions.length === 0}
+
+
+
+
Alles ruhig
+
+ Aktuell werden keine Vertretungen gesucht.
+
+
+
+
+ {:else}
+
+ {#each openSubstitutions as sub}
+
+
+
+
{sub.title}
+
{sub.date}
+
+
+
+ {sub.start_time}
+ - {sub.end_time}
+
+
+ {#if sub.notes}
+
+ {/if}
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+ {/if}
+
+
+ {#if activeView === "schedule" && (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/AdminScheduleTab.svelte b/frontend/src/components/admin/AdminScheduleTab.svelte
new file mode 100644
index 0000000..34ab17d
--- /dev/null
+++ b/frontend/src/components/admin/AdminScheduleTab.svelte
@@ -0,0 +1,420 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Neuen Eintrag erstellen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Tag | Zeit | Typ | Titel | Aktion |
+
+ {#each schedules as s (s.id)}
+
+ | {dayNames[s.dayOfWeek]} |
+ {s.startTime} - {s.endTime} |
+ {s.scheduleType === "break"
+ ? "Pause"
+ : "Unterricht"} |
+ {s.title} |
+ |
+
+ {/each}
+
+
+
+
+
diff --git a/frontend/src/components/admin/AdminSchoolYearsTab.svelte b/frontend/src/components/admin/AdminSchoolYearsTab.svelte
new file mode 100644
index 0000000..8684929
--- /dev/null
+++ b/frontend/src/components/admin/AdminSchoolYearsTab.svelte
@@ -0,0 +1,137 @@
+
+
+{#if activeSchoolYear}
+
+
+
+
+ Aktives Schuljahr: {activeSchoolYear.name}
+
+
+ {activeSchoolYear.startDate} bis {activeSchoolYear.endDate}
+
+
+
+{:else}
+
+
+ Kein Schuljahr aktiv! Bitte eines aktivieren.
+
+{/if}
+
+
+
+
+
+ | Name | Start | Ende | Status | Aktion |
+
+ {#each schoolYears as sy}
+
+ | {sy.name} |
+ {sy.startDate} |
+ {sy.endDate} |
+
+ {#if sy.isActive}
+ Aktiv
+ {:else}
+ Inaktiv
+ {/if}
+ |
+
+ {#if !sy.isActive}
+
+ {/if}
+
+ |
+
+ {/each}
+
+
+
diff --git a/frontend/src/components/admin/AdminSettingsTab.svelte b/frontend/src/components/admin/AdminSettingsTab.svelte
new file mode 100644
index 0000000..4986e0c
--- /dev/null
+++ b/frontend/src/components/admin/AdminSettingsTab.svelte
@@ -0,0 +1,154 @@
+
+
+
+
+
Schuleinstellungen
+
+
+
+
+
+
+
Lizenzierung
+
+ {#if licenseStatus}
+
+
+
Status
+
+ {licenseStatus.is_valid ? "Aktiv" : "Ungültig"}
+
+
{licenseStatus.message}
+
+
+
Schule
+
+ {licenseStatus.school_name || "-"}
+
+
+
+
Gültig bis
+
+ {licenseStatus.expires_at || "-"}
+
+
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/components/admin/AdminSubstitutionsTab.svelte b/frontend/src/components/admin/AdminSubstitutionsTab.svelte
new file mode 100644
index 0000000..086647a
--- /dev/null
+++ b/frontend/src/components/admin/AdminSubstitutionsTab.svelte
@@ -0,0 +1,144 @@
+
+
+
+
+
+
Neue Vertretung ausschreiben
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Übersicht
+
+
+
+
+ | Datum |
+ Zeit |
+ Titel |
+ Status |
+
+
+
+ {#each substitutions as s}
+
+ | {s.date} |
+ {s.start_time} - {s.end_time} |
+
+ {s.title}
+ {#if s.notes}
+ {s.notes}
+ {/if}
+ |
+
+ {#if s.taken_by_user_id}
+ Übernommen
+
+ von {s.taken_by_username}
+
+ {:else}
+ Offen
+ {/if}
+ |
+
+ {:else}
+ | Keine Einträge |
+ {/each}
+
+
+
+
+
diff --git a/frontend/src/components/admin/AdminTimeEntriesTab.svelte b/frontend/src/components/admin/AdminTimeEntriesTab.svelte
new file mode 100644
index 0000000..5a9bbc6
--- /dev/null
+++ b/frontend/src/components/admin/AdminTimeEntriesTab.svelte
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+
+
+ Jahresübersicht
+
+
+
+ | Mitarbeiter | Soll | Ist | Differenz | Status |
+
+ {#each yearlySummary as s}
+
+ | {s.username} |
+ {s.yearlyTarget} |
+ {s.yearlyActual} |
+ {s.remainingYearly.toFixed(1)} |
+
+ {#if s.remainingYearly > 0}
+ Offen
+ {:else}
+ Erfüllt
+ {/if}
+ |
+
+ {/each}
+
+
+
+
+
+
+
+
+
+ Manuelle Korrektur / Eintragung
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/admin/AdminUsersTab.svelte b/frontend/src/components/admin/AdminUsersTab.svelte
new file mode 100644
index 0000000..cde79ec
--- /dev/null
+++ b/frontend/src/components/admin/AdminUsersTab.svelte
@@ -0,0 +1,209 @@
+
+
+
+
+
+ Neuen Benutzer anlegen
+
+
+
+
+
+
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
new file mode 100644
index 0000000..b76372f
--- /dev/null
+++ b/frontend/src/lib/api.js
@@ -0,0 +1,339 @@
+import { get } from "svelte/store";
+import { auth, addToast, loading } from "./stores";
+
+const BASE_URL = "/api";
+
+function parseJwt(token) {
+ if (!token) return {};
+ try {
+ const base64Url = token.split(".")[1];
+ if (!base64Url) return {};
+ let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = base64.length % 4;
+ if (padding) base64 += "=".repeat(4 - padding);
+ const jsonPayload = decodeURIComponent(
+ window
+ .atob(base64)
+ .split("")
+ .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
+ .join(""),
+ );
+ return JSON.parse(jsonPayload);
+ } catch (e) {
+ console.error("JWT Parse Error:", e);
+ return {};
+ }
+}
+
+function mapScheduleFromApi(s) {
+ return {
+ id: s.id,
+ dayOfWeek: s.day_of_week,
+ startTime: s.start_time,
+ endTime: s.end_time,
+ scheduleType: s.type,
+ title: s.title,
+ };
+}
+function mapScheduleToApi(s) {
+ return {
+ day_of_week: parseInt(s.dayOfWeek),
+ start_time: s.startTime,
+ end_time: s.endTime,
+ type: s.scheduleType,
+ title: s.title,
+ };
+}
+function mapTimeEntryFromApi(e) {
+ return {
+ id: e.id,
+ userId: e.user_id,
+ scheduleId: e.schedule_id,
+ date: e.date,
+ entryType: e.type || e.entry_type,
+ username: e.username,
+ startTime: e.start_time,
+ endTime: e.end_time,
+ };
+}
+
+function mapUserFromApi(u) {
+ return {
+ ...u,
+ yearlyWorkHours:
+ u.yearly_hours || u.yearly_work_hours || u.yearlyWorkHours || 0,
+ isAdmin: !!(u.isAdmin || u.is_admin),
+ };
+}
+
+async function request(endpoint, method = "GET", body = null, isBlob = false) {
+ loading.set(true);
+ const token = get(auth).token;
+ const headers = {};
+
+ if (!isBlob) headers["Content-Type"] = "application/json";
+ if (token) headers["Authorization"] = `Bearer ${token}`;
+
+ try {
+ const res = await fetch(`${BASE_URL}${endpoint}`, {
+ method,
+ headers,
+ body: body ? JSON.stringify(body) : null,
+ });
+
+ loading.set(false);
+
+ if (!res.ok) {
+ if (res.status === 401) {
+ if (get(auth).isAuthenticated) {
+ addToast(
+ "Ihre Sitzung ist abgelaufen. Bitte neu anmelden.",
+ "warning",
+ );
+ logout();
+ }
+ throw new Error("Sitzung abgelaufen");
+ }
+
+ const errText = await res.text();
+ let errorMsg = errText || `Fehler: ${res.status}`;
+
+ try {
+ const jsonErr = JSON.parse(errText);
+ if (jsonErr.message) errorMsg = jsonErr.message;
+ } catch (e) {}
+
+ throw new Error(errorMsg);
+ }
+
+ if (isBlob) return await res.blob();
+ const text = await res.text();
+ return text ? JSON.parse(text) : null;
+ } catch (error) {
+ loading.set(false);
+
+ if (error.message === "Sitzung abgelaufen") {
+ throw error;
+ }
+
+ if (error.name === "TypeError" && error.message.includes("fetch")) {
+ addToast(
+ "Verbindung zum Server fehlgeschlagen. Sind Sie online?",
+ "error",
+ );
+ throw new Error("Verbindungsfehler");
+ }
+
+ if (endpoint !== "/login") {
+ addToast(error.message || "Unbekannter Fehler", "error");
+ }
+
+ throw error;
+ }
+}
+
+export const login = async (username, password) => {
+ try {
+ const data = await request("/login", "POST", { username, password });
+ const jwtData = parseJwt(data.token);
+
+ const userObj = {
+ username: data.username,
+ is_admin: data.is_admin,
+ id: jwtData.user_id || jwtData.id || data.id || 0,
+ };
+
+ auth.set({
+ token: data.token,
+ user: mapUserFromApi(userObj),
+ isAuthenticated: true,
+ });
+ addToast("Erfolgreich angemeldet", "success");
+ return true;
+ } catch (e) {
+ addToast(
+ "Anmeldung fehlgeschlagen. Prüfen Sie Benutzername und Passwort.",
+ "error",
+ );
+ return false;
+ }
+};
+
+export const logout = () => {
+ auth.set({ token: null, user: null, isAuthenticated: false });
+};
+
+export const getMyInfo = async () => {
+ const data = await request("/my-info");
+ return mapUserFromApi(data);
+};
+
+export const getSchedules = async () => {
+ const data = await request("/schedules");
+ return data.map(mapScheduleFromApi);
+};
+
+export const createSchedule = (s) => {
+ const payload = mapScheduleToApi(s);
+ return request("/admin/schedules", "POST", payload);
+};
+
+export const deleteSchedule = (id) =>
+ request(`/admin/schedules/delete?id=${id}`, "DELETE");
+
+export const getMyTimeEntries = async () => {
+ const data = await request("/my-time-entries");
+ return data.map(mapTimeEntryFromApi);
+};
+
+export const saveTimeEntriesBatch = (entries) =>
+ request("/time-entries/batch", "POST", { entries });
+export const deleteWeekEntries = (year, week) =>
+ request(`/my-time-entries/week?year=${year}&week=${week}`, "DELETE");
+
+export const getUsers = async () => {
+ const data = await request("/admin/users/list");
+ return data.map(mapUserFromApi);
+};
+
+export const createUser = (u) =>
+ request("/admin/users", "POST", {
+ username: u.username,
+ password: u.password,
+ is_admin: u.isAdmin,
+ });
+export const deleteUser = (id) =>
+ request(`/admin/users/delete?id=${id}`, "DELETE");
+
+export const updateUserWorkHours = (id, hours) =>
+ request(`/admin/users/${id}`, "PUT", { yearly_hours: parseFloat(hours) });
+export const resetUserPassword = (id, new_password) =>
+ request(`/admin/users/${id}/reset-password`, "PUT", { new_password });
+
+export const getAllTimeEntries = async () => {
+ const data = await request("/admin/time-entries");
+ return data.map(mapTimeEntryFromApi);
+};
+
+export const updateTimeEntry = (id, entry) => {
+ const payload = {
+ date: entry.date,
+ start_time: entry.startTime,
+ end_time: entry.endTime,
+ type: entry.entryType,
+ };
+ return request(`/admin/time-entries/${id}`, "PUT", payload);
+};
+
+export const deleteTimeEntry = (id) =>
+ request(`/admin/time-entries/${id}`, "DELETE");
+
+export const createAdminTimeEntry = (entry) =>
+ request("/admin/time-entry", "POST", {
+ user_id: entry.selectedUserId,
+ date: entry.date,
+ hours: parseFloat(entry.hours),
+ type: "manual",
+ });
+
+export const getYearlySummary = async () => {
+ const data = await request("/yearly-hours-summary");
+ return data.map((s) => ({
+ ...s,
+ userId: s.user_id,
+ yearlyTarget: s.yearly_target,
+ yearlyActual: s.yearly_actual,
+ remainingYearly: s.remaining_yearly,
+ }));
+};
+
+export const downloadYearlySummaryPDF = () =>
+ request("/admin/yearly-summary/pdf", "GET", null, true);
+
+export const getSchoolYears = async () => {
+ const data = await request("/admin/school-years");
+ return data.map((sy) => ({
+ ...sy,
+ startDate: sy.start_date,
+ endDate: sy.end_date,
+ isActive: sy.is_active,
+ }));
+};
+
+export const getActiveSchoolYear = async () => {
+ const sy = await request("/school-year/active");
+ if (!sy) return null;
+ return {
+ ...sy,
+ startDate: sy.start_date,
+ endDate: sy.end_date,
+ isActive: sy.is_active,
+ };
+};
+
+export const uploadLogo = async (file) => {
+ const formData = new FormData();
+ formData.append("logo", file);
+
+ const token = localStorage.getItem("token");
+
+ const res = await fetch("/api/admin/settings/logo", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: formData,
+ });
+
+ if (!res.ok) throw new Error("Upload fehlgeschlagen");
+ return true;
+};
+
+export const createSchoolYear = (sy) =>
+ request("/admin/school-years", "POST", {
+ name: sy.name,
+ start_date: sy.startDate,
+ end_date: sy.endDate,
+ });
+export const activateSchoolYear = (id) =>
+ request(`/admin/school-years/${id}/activate`, "PUT");
+export const deleteSchoolYear = (id) =>
+ request(`/admin/school-years/${id}`, "DELETE");
+export const changeMyPassword = (oldPw, newPw) =>
+ request("/change-password", "POST", {
+ old_password: oldPw,
+ new_password: newPw,
+ });
+
+export const getLicenseStatus = async () => request("/admin/settings/license");
+
+export const uploadLicense = async (file) => {
+ const formData = new FormData();
+ formData.append("license", file);
+ const token = localStorage.getItem("token");
+ const res = await fetch("/api/admin/settings/license", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: formData,
+ });
+ if (!res.ok) throw new Error("Upload fehlgeschlagen");
+ return await res.json();
+};
+
+export const getAllSubstitutions = async () => {
+ const data = await request("/admin/substitutions");
+ return data;
+};
+
+export const createSubstitution = (sub) => {
+ return request("/admin/substitutions", "POST", sub);
+};
+
+export const getOpenSubstitutions = async () => {
+ const data = await request("/substitutions/open");
+ return data;
+};
+
+export const acceptSubstitution = (id) => {
+ return request(`/substitutions/${id}/accept`, "POST");
+};
diff --git a/frontend/src/lib/stores.js b/frontend/src/lib/stores.js
new file mode 100644
index 0000000..8d63a01
--- /dev/null
+++ b/frontend/src/lib/stores.js
@@ -0,0 +1,82 @@
+import { writable, get } from "svelte/store";
+
+function safeParse(jsonString) {
+ if (!jsonString || jsonString === "undefined" || jsonString === "null")
+ return null;
+ try {
+ return JSON.parse(jsonString);
+ } catch (e) {
+ return null;
+ }
+}
+
+function decodeJwt(token) {
+ try {
+ const base64Url = token.split(".")[1];
+ const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
+ const jsonPayload = decodeURIComponent(
+ window
+ .atob(base64)
+ .split("")
+ .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
+ .join(""),
+ );
+ return JSON.parse(jsonPayload);
+ } catch (e) {
+ return null;
+ }
+}
+
+const normalizeUser = (u) => {
+ if (!u) return null;
+ return { ...u, isAdmin: !!(u.isAdmin || u.is_admin) };
+};
+
+const storedToken = localStorage.getItem("token");
+let initialUser = normalizeUser(safeParse(localStorage.getItem("user")));
+let initialAuth = false;
+
+if (storedToken) {
+ const decoded = decodeJwt(storedToken);
+ const currentTime = Date.now() / 1000;
+
+ if (decoded && decoded.exp && decoded.exp < currentTime) {
+ console.warn("Token im Storage ist abgelaufen. Auto-Logout.");
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ initialUser = null;
+ } else {
+ initialAuth = !!initialUser;
+ }
+}
+
+export const auth = writable({
+ token: initialAuth ? storedToken : null,
+ user: initialUser,
+ isAuthenticated: initialAuth,
+});
+
+auth.subscribe((value) => {
+ if (value.token && value.user) {
+ localStorage.setItem("token", value.token);
+ localStorage.setItem("user", JSON.stringify(value.user));
+ } else {
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ }
+});
+
+export const loading = writable(false);
+
+export const toasts = writable([]);
+
+export function addToast(message, type = "info") {
+ const id = Date.now() + Math.random();
+ const newToast = { id, message, type };
+ toasts.update((all) => [newToast, ...all]);
+ setTimeout(() => removeToast(id), 5000);
+}
+
+export function removeToast(id) {
+ toasts.update((all) => all.filter((t) => t.id !== id));
+}
diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js
new file mode 100644
index 0000000..cb9f7bc
--- /dev/null
+++ b/frontend/src/lib/utils.js
@@ -0,0 +1,59 @@
+export function calculateHours(startTime, endTime) {
+ if (!startTime || !endTime) return 0;
+ if (endTime === "manual") return parseFloat(startTime) || 0;
+
+ const parseTime = (timeStr) => {
+ const parts = timeStr.split(":");
+ if (parts.length !== 2) return 0;
+ return parseFloat(parts[0]) + parseFloat(parts[1]) / 60;
+ };
+
+ const start = parseTime(startTime);
+ const end = parseTime(endTime);
+
+ if (end > start) return end - start;
+ return 0;
+}
+
+export function getISOWeek(date) {
+ const d = new Date(
+ Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
+ );
+ const dayNum = d.getUTCDay() || 7;
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
+ return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
+}
+
+export function getISOYear(date) {
+ const d = new Date(
+ Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
+ );
+ const dayNum = d.getUTCDay() || 7;
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
+ return d.getUTCFullYear();
+}
+
+export function getDateOfISOWeek(w, y) {
+ const simple = new Date(y, 0, 1 + (w - 1) * 7);
+ const dow = simple.getDay();
+ const ISOweekStart = simple;
+ if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1);
+ else ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay());
+ return ISOweekStart;
+}
+
+export function formatDate(date) {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+export const dayNames = [
+ "Montag",
+ "Dienstag",
+ "Mittwoch",
+ "Donnerstag",
+ "Freitag",
+];
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..23223e4
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,9 @@
+import { mount } from "svelte";
+import "./app.css";
+import App from "./App.svelte";
+
+const app = mount(App, {
+ target: document.getElementById("app"),
+});
+
+export default app;
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..a710f1b
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,8 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
+export default {
+ // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+};
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..cb5c5ad
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ["./src/**/*.{html,js,svelte,ts}"],
+ theme: {
+ extend: {},
+ },
+ plugins: [require("daisyui")],
+ daisyui: {
+ themes: ["light", "dark"],
+ },
+};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..6278ad0
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,15 @@
+import { defineConfig } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import tailwindcss from "@tailwindcss/vite";
+
+export default defineConfig({
+ plugins: [svelte(), tailwindcss()],
+ server: {
+ proxy: {
+ "/api": {
+ target: "http://127.0.0.1:8085",
+ changeOrigin: true,
+ },
+ },
+ },
+});