diff --git a/Dockerfile b/Dockerfile
index 9d50907..adae492 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,34 +1,51 @@
-FROM node:22-alpine AS frontend-builder
+# Build stage for Elm frontend
+FROM node:25-alpine AS elm-build
-WORKDIR /src/frontend
+WORKDIR /frontend
-COPY frontend/package.json frontend/package-lock.json ./
-RUN npm ci
+# Install Elm
+RUN npm install -g elm@latest-0.19.1
-COPY frontend/ ./
-RUN npm run build
+# Copy Elm files
+COPY frontend/elm.json .
+COPY frontend/src ./src
-FROM golang:1.25.5-alpine AS backend-builder
+# Build Elm app
+RUN elm make src/Main.elm --optimize --output=elm.js
-WORKDIR /src/backend
+# Build stage for Go backend
+FROM golang:1.25.3-alpine AS go-build
+WORKDIR /app
+
+# Copy go mod files
COPY backend/go.mod backend/go.sum ./
RUN go mod download
+# Copy backend source
COPY backend/ ./
-COPY --from=frontend-builder /src/frontend/dist ./dist
-
-RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o timetracker .
+# Build Go binary
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
+# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
-WORKDIR /app
+WORKDIR /root/
-COPY --from=backend-builder /src/backend/timetracker .
+# Copy Go binary from build stage
+COPY --from=go-build /app/main .
+# 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
@@ -36,4 +53,4 @@ ENV DB_PATH=/data/timetracking.db
EXPOSE 8080
-CMD ["./timetracker"]
+CMD ["./main"]
diff --git a/README.md b/README.md
index 70397ee..732cdbb 100644
--- a/README.md
+++ b/README.md
@@ -65,9 +65,8 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
### Frontend
-- **Svelte 5**: Reaktivität und Performance.
-- **Vite**: Build-Tooling.
-- **Tailwind CSS + DaisyUI**: UI-Komponenten.
+- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI
+- **Bulma CSS**: Modernes CSS-Framework
- **Font Awesome**: Icons
- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung
@@ -94,8 +93,9 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
### Für lokale Entwicklung
-- Go 1.25+
-- Node.js 20+
+- Go 1.21+
+- Elm 0.19
+- Node.js 16+ (für Elm-Tooling)
- SQLite3
## 🚀 Installation
@@ -770,6 +770,6 @@ Todo
---
-**Version**: 1.7.0
-**Letztes Update**: Januar 2026
+**Version**: 1.5.0
+**Letztes Update**: November 2025
**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter
diff --git a/backend/database.go b/backend/database.go
index 9e123a1..66f3e54 100644
--- a/backend/database.go
+++ b/backend/database.go
@@ -4,7 +4,6 @@ import (
"database/sql"
"fmt"
"log"
- "os"
"sort"
"strconv"
"strings"
@@ -15,51 +14,20 @@ import (
)
func InitDB(filepath string) *sql.DB {
- dsn := filepath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
-
- db, err := sql.Open("sqlite", dsn)
+ db, err := sql.Open("sqlite", filepath)
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 (
@@ -88,49 +56,58 @@ 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),
- FOREIGN KEY(schedule_id) REFERENCES schedules(id)
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ FOREIGN KEY (schedule_id) REFERENCES schedules(id)
)`,
- `CREATE TABLE IF NOT EXISTS school_years (
+ `CREATE TABLE IF NOT EXISTS audit_logs (
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,
+ user_id INTEGER NOT NULL,
+ action TEXT NOT NULL,
+ details TEXT,
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,
- schedule_id INTEGER NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY(taken_by_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
)`,
}
for _, query := range queries {
- _, err := db.Exec(query)
- if err != nil {
- log.Fatalf("Error creating table: %s\nQuery: %s", err, query)
+ if _, err := db.Exec(query); err != nil {
+ log.Fatal(err)
}
}
+
+ hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
+ _, err := db.Exec(`
+ INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
+ VALUES (?, ?, ?, ?, ?)`,
+ 1, "admin", string(hash), true, 40.0,
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
}
func createIndexes(db *sql.DB) {
indexes := []string{
- "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)`,
+ `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)`,
}
+
for _, idx := range indexes {
- db.Exec(idx)
+ if _, err := db.Exec(idx); err != nil {
+ log.Printf("Warning: Failed to create index: %v", err)
+ }
}
}
@@ -591,16 +568,15 @@ func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error {
}
func calculateHours(entry TimeEntry) float64 {
- switch entry.Type {
- case "lesson":
+ if entry.Type == "lesson" {
return 1.0
- case "manual":
+ } else if entry.Type == "manual" {
hours, err := strconv.ParseFloat(entry.StartTime, 64)
if err != nil {
return 0
}
return hours
- default:
+ } else {
return calculateHoursDiff(entry.StartTime, entry.EndTime)
}
}
@@ -639,204 +615,12 @@ func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, w
for day := 0; day <= 4; day++ {
dateList = append(dateList, dates.Dates[fmt.Sprint(day)])
}
- tx, err := db.Begin()
- if err != nil {
- return err
- }
- defer tx.Rollback()
-
- _, err = tx.Exec(`
- UPDATE substitutions
- SET taken_by_user_id = NULL
- WHERE taken_by_user_id = ?
- AND date IN (?, ?, ?, ?, ?)
- `, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
- if err != nil {
- return err
- }
query := `DELETE FROM time_entries
WHERE user_id = ?
AND type != 'manual'
AND date IN (?, ?, ?, ?, ?)`
- _, err = tx.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
- if err != nil {
- return err
- }
-
- return tx.Commit()
-}
-
-func CreateSubstitution(db *sql.DB, date, start, end, title, notes string, scheduleID int) error {
- _, err := db.Exec(`
- INSERT INTO substitutions (date, start_time, end_time, title, notes, schedule_id)
- VALUES (?, ?, ?, ?, ?, ?)
- `, date, start, end, title, notes, scheduleID)
+ _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
return err
}
-
-func GetOpenSubstitutions(db *sql.DB) ([]Substitution, error) {
- today := time.Now().Format("2006-01-02")
-
- rows, err := db.Query(`
- SELECT id, date, start_time, end_time, title, notes, schedule_id, created_at
- FROM substitutions
- WHERE taken_by_user_id IS NULL
- AND date >= ?
- ORDER BY date ASC, start_time ASC
- `, today)
- 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.ScheduleID, &s.CreatedAt); err != nil {
- continue
- }
- subs = append(subs, s)
- }
- return subs, nil
-}
-
-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.schedule_id,
- s.created_at,
- 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,
- &s.ScheduleID,
- &s.CreatedAt,
- &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
-}
-
-func DeleteSubstitution(db *sql.DB, id int) error {
- tx, err := db.Begin()
- if err != nil {
- return err
- }
- defer tx.Rollback()
-
- var takenByUserID sql.NullInt64
- var scheduleID int
- var date string
-
- err = tx.QueryRow(`
- SELECT taken_by_user_id, schedule_id, date
- FROM substitutions
- WHERE id = ?
- `, id).Scan(&takenByUserID, &scheduleID, &date)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil
- }
- return err
- }
-
- if takenByUserID.Valid {
- userID := int(takenByUserID.Int64)
-
- _, err = tx.Exec(`
- DELETE FROM time_entries
- WHERE user_id = ? AND schedule_id = ? AND date = ?
- `, userID, scheduleID, date)
- if err != nil {
- return err
- }
- }
-
- _, err = tx.Exec("DELETE FROM substitutions WHERE id = ?", id)
- if err != nil {
- return err
- }
-
- return tx.Commit()
-}
-
-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 string
- var scheduleID int
- var scheduleType string
-
- err = tx.QueryRow(`
- SELECT s.date, s.start_time, s.end_time, s.schedule_id, sch.type
- FROM substitutions s
- JOIN schedules sch ON s.schedule_id = sch.id
- WHERE s.id = ? AND s.taken_by_user_id IS NULL
- `, substitutionID).Scan(¤tDate, &start, &end, &scheduleID, &scheduleType)
-
- 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 (?, ?, ?, ?, ?, ?)
- `, userID, scheduleID, currentDate, scheduleType, start, end)
- if err != nil {
- return err
- }
-
- return tx.Commit()
-}
diff --git a/backend/go.mod b/backend/go.mod
index 76a4aed..2a1d344 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -3,9 +3,7 @@ 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
@@ -14,7 +12,9 @@ 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 1d63a92..06b3f57 100644
--- a/backend/handlers.go
+++ b/backend/handlers.go
@@ -3,10 +3,8 @@ package main
import (
"database/sql"
"fmt"
- "io"
"log"
"net/http"
- "os"
"strconv"
"strings"
"time"
@@ -71,16 +69,6 @@ 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))
@@ -738,183 +726,3 @@ 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) 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!"})
-}
-
-func (app *App) CreateSubstitutionHandler(c echo.Context) error {
- var req CreateSubstitutionRequest
- if err := c.Bind(&req); err != nil {
- return HandleError(c, ErrInvalidInputMsg("Eingabedaten"))
- }
- if err := CreateSubstitution(app.DB, req.Date, req.StartTime, req.EndTime, req.Title, req.Notes, req.ScheduleID); err != nil {
- return HandleError(c, ErrDatabaseMsg(err))
- }
- return c.JSON(http.StatusCreated, map[string]string{"message": "Vertretung ausgeschrieben"})
-}
-
-func (app *App) DeleteSubstitutionHandler(c echo.Context) error {
- id, err := strconv.Atoi(c.Param("id"))
- if err != nil {
- return HandleError(c, ErrInvalidInputMsg("ID"))
- }
- if err := DeleteSubstitution(app.DB, id); err != nil {
- return HandleError(c, ErrDatabaseMsg(err))
- }
- return c.NoContent(http.StatusOK)
-}
diff --git a/backend/license.go b/backend/license.go
deleted file mode 100644
index 9882f25..0000000
--- a/backend/license.go
+++ /dev/null
@@ -1,72 +0,0 @@
-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 d374b7a..7358e39 100755
--- a/backend/load-env.sh
+++ b/backend/load-env.sh
@@ -11,7 +11,7 @@ else
fi
if [ -z "$PORT" ]; then
- export PORT=8085
+ export PORT=8080
fi
if [ -z "$DB_PATH" ]; then
diff --git a/backend/main.go b/backend/main.go
index fd9e98d..84cb7f1 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -1,10 +1,6 @@
package main
import (
- "embed"
- "fmt"
- "io"
- "io/fs"
"log"
"net/http"
"os"
@@ -14,9 +10,6 @@ import (
"github.com/labstack/echo/v4/middleware"
)
-//go:embed dist
-var frontendDist embed.FS
-
func main() {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
@@ -33,15 +26,14 @@ func main() {
e.Use(middleware.Logger())
e.Use(middleware.Recover())
- e.Use(middleware.Gzip())
-
- e.Use(middleware.Secure())
-
- allowOrigins := []string{"*"}
+ // CORS Configuration
+ allowOrigins := []string{"*"} // Default for development
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.")
}
}
@@ -54,7 +46,6 @@ func main() {
e.HTTPErrorHandler = customHTTPErrorHandler
e.POST("/api/login", app.LoginHandler)
- e.GET("/api/logo", app.GetLogoHandler)
protected := e.Group("/api")
protected.Use(JWTMiddleware())
@@ -68,10 +59,7 @@ 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")
@@ -95,43 +83,13 @@ 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)
- admin.DELETE("/substitutions/:id", app.DeleteSubstitutionHandler)
}
- 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
- })
+ e.Static("/", "./static")
port := os.Getenv("PORT")
if port == "" {
- port = "8085"
+ port = "8080"
}
log.Printf("Server starting on port %s", port)
@@ -144,9 +102,16 @@ func customHTTPErrorHandler(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
- message = fmt.Sprintf("%v", he.Message)
+ message = he.Message.(string)
}
- c.Logger().Error(err)
- c.JSON(code, map[string]string{"message": message})
+ if !c.Response().Committed {
+ if c.Request().Method == http.MethodHead {
+ c.NoContent(code)
+ } else {
+ c.JSON(code, map[string]string{
+ "error": message,
+ })
+ }
+ }
}
diff --git a/backend/models.go b/backend/models.go
index 9fc13fb..8429bb6 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"`
- YearlyActual float64 `json:"yearly_actual"`
- WeeklyTarget float64 `json:"weekly_target"`
- RemainingYearly float64 `json:"remaining_yearly"`
+ YearlyTarget float64 `json:"yearly_target"` // NEU
+ YearlyActual float64 `json:"yearly_actual"` // NEU
+ WeeklyTarget float64 `json:"weekly_target"` // NEU
+ RemainingYearly float64 `json:"remaining_yearly"` // NEU
}
type User struct {
@@ -101,50 +101,3 @@ 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"`
- ScheduleID int `json:"schedule_id"`
- 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"`
- ScheduleID int `json:"schedule_id"`
-}
diff --git a/docker-compose.yml b/docker-compose.yml
index 39e31ce..221d016 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,18 +2,23 @@ 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=change-me-to-something-secure-and-long
- - TZ=Europe/Berlin
- - CORS_ALLOWED_ORIGINS=http://localhost:8080
+ - JWT_SECRET=your-default-secret-change-me
+ - TZ=Europe/Berlin # Optional: Zeitzone
volumes:
- - timetracking_data:/data
+ - timetracking-data:/data
+ restart: unless-stopped
+ networks:
+ - timetracking-net
volumes:
- timetracking_data:
+ timetracking-data:
+ driver: local
+
+networks:
+ timetracking-net:
+ driver: bridge
diff --git a/frontend/elm.json b/frontend/elm.json
new file mode 100644
index 0000000..07196ee
--- /dev/null
+++ b/frontend/elm.json
@@ -0,0 +1,27 @@
+{
+ "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
deleted file mode 100644
index 55d0a6d..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
-
- school-timetracker
-
-
-
-
-
-
-
-
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
deleted file mode 100644
index 49869a6..0000000
--- a/frontend/jsconfig.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "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
deleted file mode 100644
index 81e876f..0000000
--- a/frontend/package-lock.json
+++ /dev/null
@@ -1,2061 +0,0 @@
-{
- "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
deleted file mode 100644
index fe2de5a..0000000
--- a/frontend/package.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "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
new file mode 100644
index 0000000..12ae1c0
--- /dev/null
+++ b/frontend/public/index.html
@@ -0,0 +1,338 @@
+
+
+
+
+
+
+
+ Zeiterfassung
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/frontend/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm
new file mode 100644
index 0000000..0de5c4e
--- /dev/null
+++ b/frontend/src/Api/Auth.elm
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000..cb72efa
--- /dev/null
+++ b/frontend/src/Api/Decoders.elm
@@ -0,0 +1,109 @@
+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
new file mode 100644
index 0000000..f966645
--- /dev/null
+++ b/frontend/src/Api/Schedule.elm
@@ -0,0 +1,120 @@
+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
new file mode 100644
index 0000000..be1fb63
--- /dev/null
+++ b/frontend/src/Api/SchoolYear.elm
@@ -0,0 +1,85 @@
+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
new file mode 100644
index 0000000..c1ebede
--- /dev/null
+++ b/frontend/src/Api/TimeEntry.elm
@@ -0,0 +1,201 @@
+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
new file mode 100644
index 0000000..17c77ac
--- /dev/null
+++ b/frontend/src/Api/User.elm
@@ -0,0 +1,110 @@
+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
deleted file mode 100644
index f7bcbde..0000000
--- a/frontend/src/App.svelte
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
- {#if !isAuthenticated}
-
- {:else if user?.isAdmin}
-
- {:else}
-
- {/if}
-
-
diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm
new file mode 100644
index 0000000..6f29eab
--- /dev/null
+++ b/frontend/src/Main.elm
@@ -0,0 +1,124 @@
+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
new file mode 100644
index 0000000..4ede617
--- /dev/null
+++ b/frontend/src/Ports.elm
@@ -0,0 +1,11 @@
+port module Ports exposing (..)
+
+import Json.Encode as Encode
+
+-- Outgoing Ports
+port saveToken : String -> Cmd msg
+port removeToken : () -> Cmd msg
+
+-- Incoming Ports
+port loadToken : (Maybe String -> msg) -> Sub msg
+
diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm
new file mode 100644
index 0000000..aae29d0
--- /dev/null
+++ b/frontend/src/Types/Api.elm
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..64911d6
--- /dev/null
+++ b/frontend/src/Types/Model.elm
@@ -0,0 +1,218 @@
+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
new file mode 100644
index 0000000..4158571
--- /dev/null
+++ b/frontend/src/Types/Msg.elm
@@ -0,0 +1,133 @@
+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
new file mode 100644
index 0000000..5b41054
--- /dev/null
+++ b/frontend/src/Types/Page.elm
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..20a1fbc
--- /dev/null
+++ b/frontend/src/Update/AuthUpdate.elm
@@ -0,0 +1,115 @@
+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
new file mode 100644
index 0000000..2312e13
--- /dev/null
+++ b/frontend/src/Update/ScheduleUpdate.elm
@@ -0,0 +1,244 @@
+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
new file mode 100644
index 0000000..0de741d
--- /dev/null
+++ b/frontend/src/Update/SchoolYearUpdate.elm
@@ -0,0 +1,139 @@
+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
new file mode 100644
index 0000000..a794944
--- /dev/null
+++ b/frontend/src/Update/TimeEntryUpdate.elm
@@ -0,0 +1,189 @@
+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
new file mode 100644
index 0000000..f384b8c
--- /dev/null
+++ b/frontend/src/Update/Update.elm
@@ -0,0 +1,811 @@
+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
new file mode 100644
index 0000000..9fd4b85
--- /dev/null
+++ b/frontend/src/Update/UserUpdate.elm
@@ -0,0 +1,196 @@
+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
new file mode 100644
index 0000000..1ea98dd
--- /dev/null
+++ b/frontend/src/Utils/DateUtils.elm
@@ -0,0 +1,338 @@
+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
new file mode 100644
index 0000000..a9746e2
--- /dev/null
+++ b/frontend/src/Utils/ErrorHandler.elm
@@ -0,0 +1,42 @@
+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
new file mode 100644
index 0000000..f5b8dc2
--- /dev/null
+++ b/frontend/src/Utils/Ports.elm
@@ -0,0 +1,20 @@
+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
new file mode 100644
index 0000000..2d74958
--- /dev/null
+++ b/frontend/src/Utils/TimeUtils.elm
@@ -0,0 +1,34 @@
+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
new file mode 100644
index 0000000..9afcfb5
--- /dev/null
+++ b/frontend/src/View/AdminDashboard.elm
@@ -0,0 +1,1165 @@
+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
new file mode 100644
index 0000000..ba3895d
--- /dev/null
+++ b/frontend/src/View/Components/Navigation.elm
@@ -0,0 +1,99 @@
+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
new file mode 100644
index 0000000..57730bb
--- /dev/null
+++ b/frontend/src/View/Components/Schedule.elm
@@ -0,0 +1,76 @@
+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
new file mode 100644
index 0000000..e55d2fe
--- /dev/null
+++ b/frontend/src/View/Components/Toast.elm
@@ -0,0 +1,66 @@
+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
new file mode 100644
index 0000000..9ed2485
--- /dev/null
+++ b/frontend/src/View/Login.elm
@@ -0,0 +1,57 @@
+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
new file mode 100644
index 0000000..60fac13
--- /dev/null
+++ b/frontend/src/View/UserDashboard.elm
@@ -0,0 +1,338 @@
+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
new file mode 100644
index 0000000..c16d910
--- /dev/null
+++ b/frontend/src/View/View.elm
@@ -0,0 +1,29 @@
+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
deleted file mode 100644
index 4c1b0c2..0000000
--- a/frontend/src/app.css
+++ /dev/null
@@ -1,2 +0,0 @@
-@import "tailwindcss";
-@plugin "daisyui";
diff --git a/frontend/src/assets/svelte.svg b/frontend/src/assets/svelte.svg
deleted file mode 100644
index c5e0848..0000000
--- a/frontend/src/assets/svelte.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/components/AdminDashboard.svelte b/frontend/src/components/AdminDashboard.svelte
deleted file mode 100644
index 82405cf..0000000
--- a/frontend/src/components/AdminDashboard.svelte
+++ /dev/null
@@ -1,388 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{$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
deleted file mode 100644
index 560ea4c..0000000
--- a/frontend/src/components/Login.svelte
+++ /dev/null
@@ -1,130 +0,0 @@
-
-
-
-
-
-

(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
deleted file mode 100644
index c25b7d8..0000000
--- a/frontend/src/components/ScheduleItem.svelte
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
- 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
deleted file mode 100644
index c95444d..0000000
--- a/frontend/src/components/ScheduleItems.svelte
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
- 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
deleted file mode 100644
index 0541026..0000000
--- a/frontend/src/components/ToastNotification.svelte
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
- {#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
deleted file mode 100644
index beef746..0000000
--- a/frontend/src/components/UserDashboard.svelte
+++ /dev/null
@@ -1,899 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
- - 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
deleted file mode 100644
index 34ab17d..0000000
--- a/frontend/src/components/admin/AdminScheduleTab.svelte
+++ /dev/null
@@ -1,420 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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
deleted file mode 100644
index 8684929..0000000
--- a/frontend/src/components/admin/AdminSchoolYearsTab.svelte
+++ /dev/null
@@ -1,137 +0,0 @@
-
-
-{#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
deleted file mode 100644
index 4986e0c..0000000
--- a/frontend/src/components/admin/AdminSettingsTab.svelte
+++ /dev/null
@@ -1,154 +0,0 @@
-
-
-
-
-
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
deleted file mode 100644
index 3aae90e..0000000
--- a/frontend/src/components/admin/AdminSubstitutionsTab.svelte
+++ /dev/null
@@ -1,417 +0,0 @@
-
-
-
-
-
-
-
Vertretung planen
-
-
-
-
-
KW {currentWeek}
-
{currentISOYear}
-
-
-
-
-
-
- Klicken Sie auf eine Stunde, um eine Vertretung auszuschreiben.
- Bereits ausgeschriebene Vertretungen werden farbig markiert.
-
-
-
-
-
-
- {#each weekDates as day}
- |
- {day.name}
-
- {day.date}
-
- |
- {/each}
-
-
-
-
- {#each weekDates as day}
-
-
- {#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
- {@const sub = findSubstitution(
- day.date,
- schedule.startTime,
- )}
-
-
- openCreateModal(
- schedule,
- day.date,
- )}
- class="relative cursor-pointer hover:scale-[1.02] transition-transform group"
- >
-
-
- {#if sub}
-
- {#if sub.taken_by_user_id}
-
- Übernommen
-
-
- {sub.taken_by_username}
-
- {:else}
-
- Gesucht
-
-
- Offen
-
- {/if}
-
-
- {sub.title}
-
-
- {/if}
-
- {#if !sub}
-
- + Erstellen
-
- {/if}
-
- {/each}
-
- {#if schedules.filter((s) => s.dayOfWeek === day.dayIndex).length === 0}
-
- - Frei -
-
- {/if}
-
- |
- {/each}
-
-
-
-
-
-
-
-
-
-
Liste aller Vertretungen
-
-
-
-
- | Datum |
- Zeit |
- Titel / Notiz |
- 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}
- ✓ {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
deleted file mode 100644
index 5a9bbc6..0000000
--- a/frontend/src/components/admin/AdminTimeEntriesTab.svelte
+++ /dev/null
@@ -1,312 +0,0 @@
-
-
-
-
-
-
-
-
-
- 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
deleted file mode 100644
index cde79ec..0000000
--- a/frontend/src/components/admin/AdminUsersTab.svelte
+++ /dev/null
@@ -1,209 +0,0 @@
-
-
-
-
-
- Neuen Benutzer anlegen
-
-
-
-
-
-
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
deleted file mode 100644
index 2134c7e..0000000
--- a/frontend/src/lib/api.js
+++ /dev/null
@@ -1,352 +0,0 @@
-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) => {
- console.log(sub.scheduleId);
- return request("/admin/substitutions", "POST", {
- title: sub.title,
- date: sub.date,
- start_time: sub.startTime,
- end_time: sub.endTime,
- notes: sub.notes,
- schedule_id: sub.scheduleId,
- });
-};
-
-export const deleteSubstitution = (id) => {
- return request(`/admin/substitutions/${id}`, "DELETE");
-};
-
-export const getOpenSubstitutions = async () => {
- const data = await request("/substitutions/open");
- console.log(data);
- 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
deleted file mode 100644
index 8d63a01..0000000
--- a/frontend/src/lib/stores.js
+++ /dev/null
@@ -1,82 +0,0 @@
-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
deleted file mode 100644
index cb9f7bc..0000000
--- a/frontend/src/lib/utils.js
+++ /dev/null
@@ -1,59 +0,0 @@
-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
deleted file mode 100644
index 23223e4..0000000
--- a/frontend/src/main.js
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index a710f1b..0000000
--- a/frontend/svelte.config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index cb5c5ad..0000000
--- a/frontend/tailwind.config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/** @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
deleted file mode 100644
index 6278ad0..0000000
--- a/frontend/vite.config.js
+++ /dev/null
@@ -1,15 +0,0 @@
-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,
- },
- },
- },
-});