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 @@ - - -
- - -
- - -
- {#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 @@ - - -
-
-
- Schul-Logo (e.target.style.display = "none")} - /> -

Zeiterfassung

-

- Willkommen zurück. Bitte melden Sie sich an, um Ihre Arbeitszeiten zu - erfassen. -

-
- -
-
-
- - -
- -
- - -
- e.key === "Enter" && handleLogin()} - /> - - -
- - -
- -
- -
-
-
-
-
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 @@ - - -
- - -
- - -
- {#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} -
- {:else} - {#if hasEntriesForWeek && !weekEditMode} - - {:else if weekEditMode} - - {:else} - - {/if} - - - -
- {#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} -
- - {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 -

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
- -
- -
-
-
- -
- - - - {#each schedules as s (s.id)} - - - - - - - - {/each} - -
TagZeitTypTitelAktion
{dayNames[s.dayOfWeek]}{s.startTime} - {s.endTime}{s.scheduleType === "break" - ? "Pause" - : "Unterricht"}{s.title}
-
- - - - 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} - -
-
-
- - -
-
- - -
-
- - -
- -
-
- -
- - - - {#each schoolYears as sy} - - - - - - - - {/each} - -
NameStartEndeStatusAktion
{sy.name}{sy.startDate}{sy.endDate} - {#if sy.isActive} - Aktiv - {:else} - Inaktiv - {/if} - - {#if !sy.isActive} - - {/if} - -
-
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

- -
- - -
-
-
- Logo (e.target.style.display = "none")} - /> -
-
- -
- -
- Empfohlen: PNG mit transparentem Hintergrund.
- Max. 2MB. -
-
-
-
-
-
-
-
-

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} - - {/each} - - - - - {#each weekDates as day} - - {/each} - - -
-
{day.name}
-
- {day.date} -
-
-
- {#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} -
-
-
-
-
- -
-
-

Liste aller Vertretungen

-
- - - - - - - - - - - - {#each substitutions as s} - - - - - - - - {:else} - - {/each} - -
DatumZeitTitel / NotizStatus
{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} - - -
Keine Einträge
-
-
-
-
- - - - - - 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 -

-
- - - - {#each yearlySummary as s} - - - - - - - - {/each} - -
MitarbeiterSollIstDifferenzStatus
{s.username}{s.yearlyTarget}{s.yearlyActual}{s.remainingYearly.toFixed(1)} - {#if s.remainingYearly > 0} - Offen - {:else} - Erfüllt - {/if} -
-
-
-
- -
-
-

- Manuelle Korrektur / Eintragung -

-
-
- - -
-
- - -
-
- - -
- -
-
-
- -
- - - - {#each timeEntries as entry (entry.id)} - {#if editingEntryId === entry.id} - - - - - - - - - {:else} - - - - - - - - - {/if} - {/each} - -
UserDatumZeitTypStundenAktion
{entry.username} -
- - -
-
- - - -
- - -
-
{entry.username}{entry.date}{entry.startTime} - {entry.endTime} - - {entry.entryType} - - {entry.entryType === "lesson" - ? "1.0" - : calculateHours( - entry.startTime, - entry.endTime, - )} -
- - -
-
-
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 -

-
-
- - -
-
- - -
-
- -
- -
-
-
- -
- - - - - - {#each users as user (user.id)} - - - - - - - - - - {/each} - -
IDNameRolleJahresstundenAktionen
{user.id}{user.username} - {#if user.isAdmin}Admin - {:else}User{/if} - - {#if editingUserId === user.id} -
- - - -
- {:else} - {user.yearlyWorkHours} h - {/if} -
- {#if resetPasswordUserId === user.id} -
- - - -
- {:else if user.id !== 1} -
- - - -
- {/if} -
-
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, - }, - }, - }, -});