Compare commits
9 commits
main
...
dev/implem
| Author | SHA1 | Date | |
|---|---|---|---|
| 53244457c1 | |||
| 3e2b6d46e6 | |||
| 8fe3d71dde | |||
| 3fadb6d86d | |||
| 62496ffab6 | |||
| 861062b320 | |||
| 59b37dc995 | |||
| e719f4565f | |||
| 5788d8c767 |
68 changed files with 6627 additions and 5605 deletions
45
Dockerfile
45
Dockerfile
|
|
@ -1,51 +1,34 @@
|
|||
# Build stage for Elm frontend
|
||||
FROM node:25-alpine AS elm-build
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
WORKDIR /src/frontend
|
||||
|
||||
# Install Elm
|
||||
RUN npm install -g elm@latest-0.19.1
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy Elm files
|
||||
COPY frontend/elm.json .
|
||||
COPY frontend/src ./src
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Build Elm app
|
||||
RUN elm make src/Main.elm --optimize --output=elm.js
|
||||
FROM golang:1.25.5-alpine AS backend-builder
|
||||
|
||||
# Build stage for Go backend
|
||||
FROM golang:1.25.3-alpine AS go-build
|
||||
WORKDIR /src/backend
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy backend source
|
||||
COPY backend/ ./
|
||||
|
||||
# Build Go binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
COPY --from=frontend-builder /src/frontend/dist ./dist
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o timetracker .
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
WORKDIR /root/
|
||||
WORKDIR /app
|
||||
|
||||
# Copy Go binary from build stage
|
||||
COPY --from=go-build /app/main .
|
||||
COPY --from=backend-builder /src/backend/timetracker .
|
||||
|
||||
# Create static directory
|
||||
RUN mkdir -p /root/static
|
||||
|
||||
# Copy Elm build artifacts
|
||||
COPY --from=elm-build /frontend/elm.js /root/static/
|
||||
COPY frontend/public/index.html /root/static/
|
||||
|
||||
# Create volume for database
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV PORT=8080
|
||||
|
|
@ -53,4 +36,4 @@ ENV DB_PATH=/data/timetracking.db
|
|||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./main"]
|
||||
CMD ["./timetracker"]
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -65,8 +65,9 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
|
|||
|
||||
### Frontend
|
||||
|
||||
- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI
|
||||
- **Bulma CSS**: Modernes CSS-Framework
|
||||
- **Svelte 5**: Reaktivität und Performance.
|
||||
- **Vite**: Build-Tooling.
|
||||
- **Tailwind CSS + DaisyUI**: UI-Komponenten.
|
||||
- **Font Awesome**: Icons
|
||||
- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung
|
||||
|
||||
|
|
@ -93,9 +94,8 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
|
|||
|
||||
### Für lokale Entwicklung
|
||||
|
||||
- Go 1.21+
|
||||
- Elm 0.19
|
||||
- Node.js 16+ (für Elm-Tooling)
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- SQLite3
|
||||
|
||||
## 🚀 Installation
|
||||
|
|
@ -770,6 +770,6 @@ Todo
|
|||
|
||||
---
|
||||
|
||||
**Version**: 1.5.0
|
||||
**Letztes Update**: November 2025
|
||||
**Version**: 1.7.0
|
||||
**Letztes Update**: Januar 2026
|
||||
**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -14,20 +15,51 @@ import (
|
|||
)
|
||||
|
||||
func InitDB(filepath string) *sql.DB {
|
||||
db, err := sql.Open("sqlite", filepath)
|
||||
dsn := filepath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
createTables(db)
|
||||
createIndexes(db)
|
||||
|
||||
ensureAdminExists(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func ensureAdminExists(db *sql.DB) {
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if count == 0 {
|
||||
var pw []byte
|
||||
if os.Getenv("INITIAL_ADMIN_PASSWORD") == "" {
|
||||
log.Println("Keine Benutzer gefunden. Erstelle Standard-Admin...")
|
||||
pw, _ = bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
} else {
|
||||
initialPassword := os.Getenv("INITIAL_ADMIN_PASSWORD")
|
||||
pw, _ = bcrypt.GenerateFromPassword([]byte(initialPassword), bcrypt.DefaultCost)
|
||||
}
|
||||
_, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)",
|
||||
"admin", string(pw), true, 0)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Erstellen des Admins: %v", err)
|
||||
} else {
|
||||
log.Println("Admin erstellt. User: 'admin', Pass: 'admin123'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createTables(db *sql.DB) {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
|
|
@ -56,58 +88,49 @@ func createTables(db *sql.DB) {
|
|||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (schedule_id) REFERENCES schedules(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(schedule_id) REFERENCES schedules(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS school_years (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS substitutions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
taken_by_user_id INTEGER,
|
||||
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)
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
_, err := db.Exec(`
|
||||
INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
1, "admin", string(hash), true, 40.0,
|
||||
)
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("Error creating table: %s\nQuery: %s", err, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createIndexes(db *sql.DB) {
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
|
||||
`CREATE INDEX IF NOT EXISTS idx_substitutions_date ON substitutions(date)`,
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
if _, err := db.Exec(idx); err != nil {
|
||||
log.Printf("Warning: Failed to create index: %v", err)
|
||||
}
|
||||
db.Exec(idx)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -568,15 +591,16 @@ func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error {
|
|||
}
|
||||
|
||||
func calculateHours(entry TimeEntry) float64 {
|
||||
if entry.Type == "lesson" {
|
||||
switch entry.Type {
|
||||
case "lesson":
|
||||
return 1.0
|
||||
} else if entry.Type == "manual" {
|
||||
case "manual":
|
||||
hours, err := strconv.ParseFloat(entry.StartTime, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return hours
|
||||
} else {
|
||||
default:
|
||||
return calculateHoursDiff(entry.StartTime, entry.EndTime)
|
||||
}
|
||||
}
|
||||
|
|
@ -615,12 +639,204 @@ 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 := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
|
||||
_, 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)
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ module school-timetracker
|
|||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1
|
||||
github.com/labstack/echo/v4 v4.13.4
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/time v0.11.0
|
||||
|
|
@ -12,9 +14,7 @@ require (
|
|||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/labstack/echo-jwt/v4 v4.3.1 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package main
|
|||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -69,6 +71,16 @@ func (app *App) LoginHandler(c echo.Context) error {
|
|||
return HandleError(c, ErrInvalidCredentialsMsg())
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
_, err := VerifyLicenseFile()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{
|
||||
"error": "Lizenzfehler: " + err.Error(),
|
||||
"code": "LICENSE_INVALID",
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
token, err := createToken(user.ID, user.Username, user.IsAdmin)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
|
|
@ -726,3 +738,183 @@ 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)
|
||||
}
|
||||
|
|
|
|||
72
backend/license.go
Normal file
72
backend/license.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const PublicKeyHex = "ab8287380d4f26b66b3e8067e179a2e304dcb3c4070963edd213cca9b225978f"
|
||||
|
||||
func VerifyLicenseFile() (*LicenseData, error) {
|
||||
bytes, err := os.ReadFile("license.lic")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Keine Lizenzdatei gefunden")
|
||||
}
|
||||
|
||||
var lic LicenseFile
|
||||
if err := json.Unmarshal(bytes, &lic); err != nil {
|
||||
return nil, fmt.Errorf("Lizenzdatei beschädigt")
|
||||
}
|
||||
|
||||
pubBytes, _ := hex.DecodeString(PublicKeyHex)
|
||||
publicKey := ed25519.PublicKey(pubBytes)
|
||||
|
||||
dataBytes, _ := json.Marshal(lic.Data)
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(lic.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Signatur ungültig")
|
||||
}
|
||||
|
||||
if !ed25519.Verify(publicKey, dataBytes, sigBytes) {
|
||||
return nil, fmt.Errorf("Lizenz-Signatur ungültig (Manipuliert?)")
|
||||
}
|
||||
|
||||
expiry, err := time.Parse("2006-01-02", lic.Data.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Ungültiges Datumsformat")
|
||||
}
|
||||
if time.Now().After(expiry) {
|
||||
return &lic.Data, fmt.Errorf("Lizenz abgelaufen am %s", lic.Data.ExpiresAt)
|
||||
}
|
||||
|
||||
return &lic.Data, nil
|
||||
}
|
||||
|
||||
func GetCurrentLicenseStatus(db *any) LicenseStatus {
|
||||
lic, err := VerifyLicenseFile()
|
||||
status := LicenseStatus{
|
||||
IsValid: err == nil,
|
||||
Message: "Gültig",
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
status.Message = err.Error()
|
||||
if lic != nil {
|
||||
status.SchoolName = lic.SchoolName
|
||||
status.ExpiresAt = lic.ExpiresAt
|
||||
status.MaxUsers = lic.MaxUsers
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
status.SchoolName = lic.SchoolName
|
||||
status.ExpiresAt = lic.ExpiresAt
|
||||
status.MaxUsers = lic.MaxUsers
|
||||
|
||||
return status
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ else
|
|||
fi
|
||||
|
||||
if [ -z "$PORT" ]; then
|
||||
export PORT=8080
|
||||
export PORT=8085
|
||||
fi
|
||||
|
||||
if [ -z "$DB_PATH" ]; then
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -10,6 +14,9 @@ import (
|
|||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var frontendDist embed.FS
|
||||
|
||||
func main() {
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
|
|
@ -26,14 +33,15 @@ func main() {
|
|||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
// CORS Configuration
|
||||
allowOrigins := []string{"*"} // Default for development
|
||||
e.Use(middleware.Gzip())
|
||||
|
||||
e.Use(middleware.Secure())
|
||||
|
||||
allowOrigins := []string{"*"}
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||
if origins != "" {
|
||||
allowOrigins = strings.Split(origins, ",")
|
||||
} else {
|
||||
log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +54,7 @@ func main() {
|
|||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
e.POST("/api/login", app.LoginHandler)
|
||||
e.GET("/api/logo", app.GetLogoHandler)
|
||||
|
||||
protected := e.Group("/api")
|
||||
protected.Use(JWTMiddleware())
|
||||
|
|
@ -59,7 +68,10 @@ func main() {
|
|||
protected.GET("/week-has-entries", app.CheckWeekHasEntries)
|
||||
protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
|
||||
protected.GET("/my-info", app.GetMyInfoHandler)
|
||||
protected.POST("/change-password", app.ChangeMyPasswordHandler)
|
||||
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
|
||||
protected.GET("/substitutions/open", app.GetOpenSubstitutionsHandler)
|
||||
protected.POST("/substitutions/:id/accept", app.AcceptSubstitutionHandler)
|
||||
}
|
||||
|
||||
admin := e.Group("/api/admin")
|
||||
|
|
@ -83,13 +95,43 @@ 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)
|
||||
}
|
||||
|
||||
e.Static("/", "./static")
|
||||
distDir, err := fs.Sub(frontendDist, "dist")
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Laden des eingebetteten Frontends:", err)
|
||||
}
|
||||
|
||||
fileHandler := http.FileServer(http.FS(distDir))
|
||||
e.GET("/*", func(c echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
f, err := distDir.Open(strings.TrimPrefix(path, "/"))
|
||||
if err == nil {
|
||||
f.Close()
|
||||
fileHandler.ServeHTTP(c.Response(), c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
index, err := distDir.Open("index.html")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Frontend index.html missing")
|
||||
}
|
||||
defer index.Close()
|
||||
|
||||
stat, _ := index.Stat()
|
||||
http.ServeContent(c.Response(), c.Request(), "index.html", stat.ModTime(), index.(io.ReadSeeker))
|
||||
return nil
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
port = "8085"
|
||||
}
|
||||
|
||||
log.Printf("Server starting on port %s", port)
|
||||
|
|
@ -102,16 +144,9 @@ func customHTTPErrorHandler(err error, c echo.Context) {
|
|||
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
code = he.Code
|
||||
message = he.Message.(string)
|
||||
message = fmt.Sprintf("%v", he.Message)
|
||||
}
|
||||
|
||||
if !c.Response().Committed {
|
||||
if c.Request().Method == http.MethodHead {
|
||||
c.NoContent(code)
|
||||
} else {
|
||||
c.JSON(code, map[string]string{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
c.JSON(code, map[string]string{"message": message})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ type WeeklyHours struct {
|
|||
Week int `json:"week"`
|
||||
Year int `json:"year"`
|
||||
TotalHours float64 `json:"total_hours"`
|
||||
YearlyTarget float64 `json:"yearly_target"` // NEU
|
||||
YearlyActual float64 `json:"yearly_actual"` // NEU
|
||||
WeeklyTarget float64 `json:"weekly_target"` // NEU
|
||||
RemainingYearly float64 `json:"remaining_yearly"` // NEU
|
||||
YearlyTarget float64 `json:"yearly_target"`
|
||||
YearlyActual float64 `json:"yearly_actual"`
|
||||
WeeklyTarget float64 `json:"weekly_target"`
|
||||
RemainingYearly float64 `json:"remaining_yearly"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
|
@ -101,3 +101,50 @@ 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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,18 @@ services:
|
|||
timetracking:
|
||||
build: .
|
||||
container_name: school-timetracking
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- PORT=8080
|
||||
- ENVIRONMENT=production
|
||||
- DB_PATH=/data/timetracking.db
|
||||
- JWT_SECRET=your-default-secret-change-me
|
||||
- TZ=Europe/Berlin # Optional: Zeitzone
|
||||
- JWT_SECRET=change-me-to-something-secure-and-long
|
||||
- TZ=Europe/Berlin
|
||||
- CORS_ALLOWED_ORIGINS=http://localhost:8080
|
||||
volumes:
|
||||
- timetracking-data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- timetracking-net
|
||||
- timetracking_data:/data
|
||||
|
||||
volumes:
|
||||
timetracking-data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
timetracking-net:
|
||||
driver: bridge
|
||||
timetracking_data:
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/time": "1.0.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/url": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.3"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>school-timetracker</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
39
frontend/jsconfig.json
Normal file
39
frontend/jsconfig.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
/**
|
||||
* svelte-preprocess cannot figure out whether you have
|
||||
* a value or a type, so tell TypeScript to enforce using
|
||||
* `import type` instead of `import` for Types.
|
||||
*/
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* To have warnings / errors of the Svelte compiler at the
|
||||
* correct position, enable source maps by default.
|
||||
*/
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable this if you'd like to use dynamic types.
|
||||
*/
|
||||
"checkJs": true
|
||||
},
|
||||
/**
|
||||
* Use global.d.ts instead of compilerOptions.types
|
||||
* to avoid limiting type declarations.
|
||||
*/
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.svelte"
|
||||
]
|
||||
}
|
||||
2061
frontend/package-lock.json
generated
Normal file
2061
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "school-timetracker",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.43.8",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"daisyui": "^5.5.14",
|
||||
"tailwindcss": "^4.1.18"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Zeiterfassung</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
|
||||
<style>
|
||||
/* Toast-Container */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Basis-Toast */
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: all;
|
||||
min-width: 320px;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.toast:hover {
|
||||
transform: translateX(-5px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Toast-Content */
|
||||
.toast-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close-Button */
|
||||
.toast-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
transition: color 0.2s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Toast-Typen */
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
|
||||
border-left-color: #e53e3e;
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
|
||||
border-left-color: #38a169;
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
|
||||
border-left-color: #3182ce;
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3182ce;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
|
||||
border-left-color: #dd6b20;
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #dd6b20;
|
||||
}
|
||||
|
||||
/* Animationen */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.dismissing {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Mobile Anpassungen */
|
||||
@media screen and (max-width: 768px) {
|
||||
.toast-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Mode Support (optional) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.toast {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.level {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.level-left,
|
||||
.level-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-spinner {
|
||||
animation: fa-spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes fa-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="elm"></div>
|
||||
|
||||
<script src="/elm.js"></script>
|
||||
<script>
|
||||
function getStoredData() {
|
||||
try {
|
||||
const data = localStorage.getItem("timetracking");
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stored data:", e);
|
||||
}
|
||||
return {token: null, isAdmin: false};
|
||||
}
|
||||
|
||||
function saveData(token, isAdmin) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"timetracking",
|
||||
JSON.stringify({
|
||||
token: token,
|
||||
isAdmin: isAdmin,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to save data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearData() {
|
||||
try {
|
||||
localStorage.removeItem("timetracking");
|
||||
} catch (e) {
|
||||
console.error("Failed to clear data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const storedData = getStoredData();
|
||||
const app = Elm.Main.init({
|
||||
node: document.getElementById("elm"),
|
||||
flags: {
|
||||
token: storedData.token,
|
||||
isAdmin: storedData.isAdmin,
|
||||
},
|
||||
});
|
||||
|
||||
app.ports.saveToken.subscribe(function (data) {
|
||||
saveData(data.token, data.isAdmin);
|
||||
});
|
||||
|
||||
app.ports.removeToken.subscribe(function () {
|
||||
clearData();
|
||||
});
|
||||
|
||||
app.ports.confirmDelete.subscribe(function (message) {
|
||||
const confirmed = confirm(message);
|
||||
app.ports.confirmDeleteResponse.send(confirmed);
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
function setupBurgerMenu() {
|
||||
const burgers = document.querySelectorAll(".navbar-burger");
|
||||
|
||||
burgers.forEach((burger) => {
|
||||
burger.addEventListener("click", () => {
|
||||
const target = burger.dataset.target;
|
||||
const menu = document.getElementById(target);
|
||||
|
||||
if (menu) {
|
||||
burger.classList.toggle("is-active");
|
||||
menu.classList.toggle("is-active");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupBurgerMenu();
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
setupBurgerMenu();
|
||||
});
|
||||
|
||||
observer.observe(document.getElementById("elm"), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
"serviceWorker" in navigator &&
|
||||
window.location.protocol === "https:"
|
||||
) {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||
console.log("Service Worker registration failed");
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,21 +0,0 @@
|
|||
module Api.Auth exposing (loginRequest)
|
||||
|
||||
import Api.Decoders exposing (loginDecoder)
|
||||
import Http
|
||||
import Json.Encode as Encode
|
||||
import Types.Api exposing (LoginResult)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
loginRequest : String -> String -> Cmd Msg
|
||||
loginRequest username password =
|
||||
Http.post
|
||||
{ url = "/api/login"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "username", Encode.string username )
|
||||
, ( "password", Encode.string password )
|
||||
]
|
||||
, expect = Http.expectJson LoginResponse loginDecoder
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
module Api.Decoders exposing
|
||||
( apiErrorDecoder
|
||||
, loginDecoder
|
||||
, scheduleDecoder
|
||||
, schoolYearDecoder
|
||||
, timeEntryDecoder
|
||||
, userDecoder
|
||||
, weekDatesDecoder
|
||||
, weeklyHoursDecoder
|
||||
, yearlyHoursSummaryDecoder
|
||||
)
|
||||
|
||||
import Dict
|
||||
import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string)
|
||||
import Types.Api exposing (ApiError, LoginResult)
|
||||
import Types.Model exposing (..)
|
||||
|
||||
|
||||
loginDecoder : Decoder LoginResult
|
||||
loginDecoder =
|
||||
Decode.map3 LoginResult
|
||||
(field "token" string)
|
||||
(field "username" string)
|
||||
(field "is_admin" bool)
|
||||
|
||||
|
||||
scheduleDecoder : Decoder Schedule
|
||||
scheduleDecoder =
|
||||
Decode.map6 Schedule
|
||||
(field "id" int)
|
||||
(field "day_of_week" int)
|
||||
(field "start_time" string)
|
||||
(field "end_time" string)
|
||||
(field "type" string)
|
||||
(field "title" string)
|
||||
|
||||
|
||||
timeEntryDecoder : Decoder TimeEntry
|
||||
timeEntryDecoder =
|
||||
Decode.map8 TimeEntry
|
||||
(field "id" int)
|
||||
(field "user_id" int)
|
||||
(field "schedule_id" int)
|
||||
(field "date" string)
|
||||
(field "type" string)
|
||||
(field "username" string)
|
||||
(field "start_time" string)
|
||||
(field "end_time" string)
|
||||
|
||||
|
||||
userDecoder : Decoder User
|
||||
userDecoder =
|
||||
Decode.map4 User
|
||||
(field "id" int)
|
||||
(field "username" string)
|
||||
(field "is_admin" bool)
|
||||
(field "yearly_hours" float)
|
||||
|
||||
|
||||
weekDatesDecoder : Decoder WeekDates
|
||||
weekDatesDecoder =
|
||||
Decode.map4 WeekDates
|
||||
(field "year" int)
|
||||
(field "week" int)
|
||||
(field "dates" (Decode.dict string) |> Decode.map Dict.toList)
|
||||
(field "range" string)
|
||||
|
||||
|
||||
weeklyHoursDecoder : Decoder WeeklyHours
|
||||
weeklyHoursDecoder =
|
||||
Decode.map7 WeeklyHours
|
||||
(field "user_id" int)
|
||||
(field "username" string)
|
||||
(field "year" int)
|
||||
(field "week" int)
|
||||
(field "total_hours" float)
|
||||
(field "expected_hours" float)
|
||||
(field "remaining_hours" float)
|
||||
|
||||
|
||||
yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary
|
||||
yearlyHoursSummaryDecoder =
|
||||
Decode.succeed YearlyHoursSummary
|
||||
|> Decode.andThen (\f -> Decode.map f (field "user_id" int))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "username" string))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "year" int))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "week" int))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "total_hours" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "yearly_target" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "weekly_target" float))
|
||||
|> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float))
|
||||
|
||||
|
||||
schoolYearDecoder : Decoder SchoolYear
|
||||
schoolYearDecoder =
|
||||
Decode.map5 SchoolYear
|
||||
(field "id" int)
|
||||
(field "name" string)
|
||||
(field "start_date" string)
|
||||
(field "end_date" string)
|
||||
(field "is_active" bool)
|
||||
|
||||
|
||||
apiErrorDecoder : Decoder ApiError
|
||||
apiErrorDecoder =
|
||||
Decode.map2 ApiError
|
||||
(field "code" string)
|
||||
(field "message" string)
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
module Api.Schedule exposing
|
||||
( createSchedule
|
||||
, deleteSchedule
|
||||
, fetchSchedules
|
||||
, saveTimeEntriesForWeek
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (scheduleDecoder)
|
||||
import Http
|
||||
import Json.Decode
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchSchedules : Maybe String -> Cmd Msg
|
||||
fetchSchedules maybeToken =
|
||||
case maybeToken of
|
||||
Just token ->
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/schedules"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
createSchedule : String -> NewSchedule -> Cmd Msg
|
||||
createSchedule token schedule =
|
||||
case String.toInt schedule.dayOfWeek of
|
||||
Just day ->
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/schedules"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "day_of_week", Encode.int day )
|
||||
, ( "start_time", Encode.string schedule.startTime )
|
||||
, ( "end_time", Encode.string schedule.endTime )
|
||||
, ( "type", Encode.string schedule.scheduleType )
|
||||
, ( "title", Encode.string schedule.title )
|
||||
]
|
||||
, expect = Http.expectWhatever ScheduleCreated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
deleteSchedule : String -> Int -> Cmd Msg
|
||||
deleteSchedule token scheduleId =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever ScheduleDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg
|
||||
saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates =
|
||||
case maybeWeekDates of
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
Just weekDates ->
|
||||
let
|
||||
getScheduleById id =
|
||||
List.filter (\s -> s.id == id) schedules |> List.head
|
||||
|
||||
getDateForDay dayOfWeek =
|
||||
weekDates.dates
|
||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|
||||
createEntryData entry =
|
||||
case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of
|
||||
( Just schedule, Just dateStr ) ->
|
||||
Just <|
|
||||
Encode.object
|
||||
[ ( "schedule_id", Encode.int entry.scheduleId )
|
||||
, ( "date", Encode.string dateStr )
|
||||
, ( "type", Encode.string schedule.scheduleType )
|
||||
, ( "start_time", Encode.string schedule.startTime )
|
||||
, ( "end_time", Encode.string schedule.endTime )
|
||||
]
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
entriesData =
|
||||
List.filterMap createEntryData selectedEntries
|
||||
in
|
||||
if List.isEmpty entriesData then
|
||||
Cmd.none
|
||||
|
||||
else
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/time-entries/batch"
|
||||
, body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ]
|
||||
, expect = Http.expectWhatever TimeEntriesSaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
module Api.SchoolYear exposing
|
||||
( activateSchoolYear
|
||||
, createSchoolYear
|
||||
, deleteSchoolYear
|
||||
, fetchActiveSchoolYear
|
||||
, fetchSchoolYears
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (schoolYearDecoder)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (NewSchoolYear)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchSchoolYears : String -> Cmd Msg
|
||||
fetchSchoolYears token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchActiveSchoolYear : String -> Cmd Msg
|
||||
fetchActiveSchoolYear token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/school-year/active"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
createSchoolYear : String -> NewSchoolYear -> Cmd Msg
|
||||
createSchoolYear token schoolYear =
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "name", Encode.string schoolYear.name )
|
||||
, ( "start_date", Encode.string schoolYear.startDate )
|
||||
, ( "end_date", Encode.string schoolYear.endDate )
|
||||
]
|
||||
, expect = Http.expectWhatever SchoolYearCreated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
activateSchoolYear : String -> Int -> Cmd Msg
|
||||
activateSchoolYear token id =
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever SchoolYearActivated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteSchoolYear : String -> Int -> Cmd Msg
|
||||
deleteSchoolYear token id =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/school-years/" ++ String.fromInt id
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever SchoolYearDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
module Api.TimeEntry exposing
|
||||
( checkWeekHasEntries
|
||||
, createAdminTimeEntry
|
||||
, deleteTimeEntry
|
||||
, deleteWeekEntries
|
||||
, downloadYearlySummaryPDF
|
||||
, fetchAllTimeEntries
|
||||
, fetchMyTimeEntries
|
||||
, fetchWeekDates
|
||||
, fetchWeeklyHours
|
||||
, fetchYearlyHoursSummary
|
||||
, updateTimeEntry
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder)
|
||||
import Bytes exposing (Bytes)
|
||||
import Http
|
||||
import Json.Decode as Decode exposing (bool, field)
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (AdminManualEntry, EditingTimeEntry)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchMyTimeEntries : String -> Cmd Msg
|
||||
fetchMyTimeEntries token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/my-time-entries"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchAllTimeEntries : String -> Cmd Msg
|
||||
fetchAllTimeEntries token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entries"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchWeekDates : String -> Int -> Int -> Cmd Msg
|
||||
fetchWeekDates token year week =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson WeekDatesReceived weekDatesDecoder
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
checkWeekHasEntries : String -> Int -> Int -> Cmd Msg
|
||||
checkWeekHasEntries token year week =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteWeekEntries : String -> Int -> Int -> Cmd Msg
|
||||
deleteWeekEntries token year week =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever WeekEntriesDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg
|
||||
updateTimeEntry token entry =
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "date", Encode.string entry.date )
|
||||
, ( "start_time", Encode.string entry.startTime )
|
||||
, ( "end_time", Encode.string entry.endTime )
|
||||
, ( "type", Encode.string entry.entryType )
|
||||
]
|
||||
, expect = Http.expectWhatever TimeEntrySaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteTimeEntry : String -> Int -> Cmd Msg
|
||||
deleteTimeEntry token entryId =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entries/" ++ String.fromInt entryId
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever TimeEntryDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg
|
||||
createAdminTimeEntry token entry =
|
||||
case entry.selectedUserId of
|
||||
Just userId ->
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/time-entry"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "user_id", Encode.int userId )
|
||||
, ( "date", Encode.string entry.date )
|
||||
, ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) )
|
||||
, ( "type", Encode.string "manual" )
|
||||
]
|
||||
, expect = Http.expectWhatever AdminTimeEntrySaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
fetchYearlyHoursSummary : String -> Cmd Msg
|
||||
fetchYearlyHoursSummary token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/yearly-hours-summary"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
downloadYearlySummaryPDF : String -> Cmd Msg
|
||||
downloadYearlySummaryPDF token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/yearly-summary/pdf"
|
||||
, body = Http.emptyBody
|
||||
, expect =
|
||||
Http.expectBytesResponse YearlySummaryPDFReceived
|
||||
(\response ->
|
||||
case response of
|
||||
Http.GoodStatus_ _ body ->
|
||||
Ok body
|
||||
|
||||
Http.BadUrl_ url ->
|
||||
Err (Http.BadUrl url)
|
||||
|
||||
Http.Timeout_ ->
|
||||
Err Http.Timeout
|
||||
|
||||
Http.NetworkError_ ->
|
||||
Err Http.NetworkError
|
||||
|
||||
Http.BadStatus_ metadata _ ->
|
||||
Err (Http.BadStatus metadata.statusCode)
|
||||
)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchWeeklyHours : String -> Cmd Msg
|
||||
fetchWeeklyHours token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/weekly-hours"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
module Api.User exposing
|
||||
( createUser
|
||||
, deleteUser
|
||||
, fetchMyInfo
|
||||
, fetchUsers
|
||||
, resetUserPassword
|
||||
, updateUserWorkHours
|
||||
)
|
||||
|
||||
import Api.Decoders exposing (userDecoder)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
import Types.Model exposing (NewUser)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
fetchUsers : String -> Cmd Msg
|
||||
fetchUsers token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/list"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson UsersReceived (Decode.list userDecoder)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
fetchMyInfo : String -> Cmd Msg
|
||||
fetchMyInfo token =
|
||||
Http.request
|
||||
{ method = "GET"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/my-info"
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectJson MyInfoReceived userDecoder
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
createUser : String -> NewUser -> Cmd Msg
|
||||
createUser token user =
|
||||
Http.request
|
||||
{ method = "POST"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "username", Encode.string user.username )
|
||||
, ( "password", Encode.string user.password )
|
||||
, ( "is_admin", Encode.bool user.isAdmin )
|
||||
]
|
||||
, expect = Http.expectWhatever UserCreated
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
deleteUser : String -> Int -> Cmd Msg
|
||||
deleteUser token userId =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/delete?id=" ++ String.fromInt userId
|
||||
, body = Http.emptyBody
|
||||
, expect = Http.expectWhatever UserDeleted
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
updateUserWorkHours : String -> Int -> String -> Cmd Msg
|
||||
updateUserWorkHours token userId hours =
|
||||
case String.toFloat hours of
|
||||
Just workHours ->
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/" ++ String.fromInt userId
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "yearly_hours", Encode.float workHours ) ]
|
||||
, expect = Http.expectWhatever UserWorkHoursSaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
|
||||
resetUserPassword : String -> Int -> String -> Cmd Msg
|
||||
resetUserPassword token userId newPassword =
|
||||
Http.request
|
||||
{ method = "PUT"
|
||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
||||
, url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password"
|
||||
, body =
|
||||
Http.jsonBody <|
|
||||
Encode.object
|
||||
[ ( "new_password", Encode.string newPassword ) ]
|
||||
, expect = Http.expectWhatever ResetPasswordSaved
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
61
frontend/src/App.svelte
Normal file
61
frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script>
|
||||
import { auth } from "./lib/stores";
|
||||
import Login from "./components/Login.svelte";
|
||||
import UserDashboard from "./components/UserDashboard.svelte";
|
||||
import AdminDashboard from "./components/AdminDashboard.svelte";
|
||||
import ToastNotification from "./components/ToastNotification.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { addToast } from "./lib/stores";
|
||||
|
||||
$: user = $auth.user;
|
||||
$: isAuthenticated = $auth.isAuthenticated;
|
||||
|
||||
onMount(() => {
|
||||
const darkModeMediaQuery = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
);
|
||||
|
||||
const applyTheme = (e) => {
|
||||
const isDark = e.matches;
|
||||
const theme = isDark ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
};
|
||||
|
||||
applyTheme(darkModeMediaQuery);
|
||||
|
||||
darkModeMediaQuery.addEventListener("change", applyTheme);
|
||||
const handleRejection = (event) => {
|
||||
console.error("Unerwarteter Fehler (Promise):", event.reason);
|
||||
if (event.reason && event.reason.message !== "Sitzung abgelaufen") {
|
||||
addToast("Ein unerwarteter Fehler ist aufgetreten.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (event) => {
|
||||
console.error("Kritischer Fehler:", event.error);
|
||||
addToast("Kritischer Anwendungsfehler. Bitte neu laden.", "error");
|
||||
};
|
||||
|
||||
window.addEventListener("unhandledrejection", handleRejection);
|
||||
window.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("unhandledrejection", handleRejection);
|
||||
window.removeEventListener("error", handleError);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-container">
|
||||
<ToastNotification />
|
||||
|
||||
<main>
|
||||
{#if !isAuthenticated}
|
||||
<Login />
|
||||
{:else if user?.isAdmin}
|
||||
<AdminDashboard />
|
||||
{:else}
|
||||
<UserDashboard />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
module Main exposing (..)
|
||||
|
||||
import Api.Auth exposing (..)
|
||||
import Api.Decoders exposing (..)
|
||||
import Api.Schedule exposing (..)
|
||||
import Api.SchoolYear exposing (..)
|
||||
import Api.TimeEntry exposing (..)
|
||||
import Api.User exposing (..)
|
||||
import Browser
|
||||
import Task
|
||||
import Time
|
||||
import Types.Model exposing (..)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (..)
|
||||
import Update.Update exposing (update)
|
||||
import Utils.Ports exposing (..)
|
||||
import View.View exposing (view)
|
||||
|
||||
|
||||
|
||||
-- MAIN
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.element
|
||||
{ init = init
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, view = view
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> ( Model, Cmd Msg )
|
||||
init flags =
|
||||
let
|
||||
initialPage =
|
||||
case flags.token of
|
||||
Just _ ->
|
||||
if flags.isAdmin then
|
||||
AdminDashboard
|
||||
|
||||
else
|
||||
UserDashboard
|
||||
|
||||
Nothing ->
|
||||
LoginPage
|
||||
|
||||
model =
|
||||
{ page = initialPage
|
||||
, activeTab = ScheduleTab
|
||||
, username = ""
|
||||
, password = ""
|
||||
, token = flags.token
|
||||
, isAdmin = flags.isAdmin
|
||||
, schedules = []
|
||||
, users = []
|
||||
, timeEntries = []
|
||||
, weeklyHours = []
|
||||
, yearlyHoursSummary = []
|
||||
, selectedEntries = []
|
||||
, currentWeek = 1
|
||||
, currentYear = 2025
|
||||
, currentTime = Time.millisToPosix 0
|
||||
, zone = Time.utc
|
||||
, newSchedule = NewSchedule "" "" "" "lesson" ""
|
||||
, newUser = NewUser "" "" False
|
||||
, error = Nothing
|
||||
, weekEditMode = False
|
||||
, hasEntriesForCurrentWeek = False
|
||||
, weekDates = Nothing
|
||||
, userWeeklySummary = Nothing
|
||||
, editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
|
||||
, editingUserId = Nothing
|
||||
, editingUserWorkHours = ""
|
||||
, resetPasswordUserId = Nothing
|
||||
, resetPasswordNew = ""
|
||||
, pendingDeleteId = Nothing
|
||||
, selectedUserId = Nothing
|
||||
, userWorkHoursInput = ""
|
||||
, userPasswordInput = ""
|
||||
, isProcessing = False
|
||||
, mobileMenuOpen = False
|
||||
, adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
|
||||
, schoolYears = []
|
||||
, newSchoolYear = NewSchoolYear "" "" ""
|
||||
, activeSchoolYear = Nothing
|
||||
, editingSchoolYearId = Nothing
|
||||
, toasts = []
|
||||
, nextToastId = 0
|
||||
}
|
||||
|
||||
cmd =
|
||||
case flags.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Task.perform SetTime Time.now
|
||||
, fetchSchedules (Just token)
|
||||
, fetchYearlyHoursSummary token
|
||||
, if flags.isAdmin then
|
||||
Cmd.batch
|
||||
[ fetchSchoolYears token
|
||||
, fetchUsers token
|
||||
, fetchAllTimeEntries token
|
||||
]
|
||||
|
||||
else
|
||||
fetchMyInfo token
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Task.perform SetTime Time.now
|
||||
in
|
||||
( model, cmd )
|
||||
|
||||
|
||||
|
||||
-- SUBSCRIPTIONS
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
confirmDeleteResponse DeleteConfirmed
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
port module Ports exposing (..)
|
||||
|
||||
import Json.Encode as Encode
|
||||
|
||||
-- Outgoing Ports
|
||||
port saveToken : String -> Cmd msg
|
||||
port removeToken : () -> Cmd msg
|
||||
|
||||
-- Incoming Ports
|
||||
port loadToken : (Maybe String -> msg) -> Sub msg
|
||||
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
module Types.Api exposing
|
||||
( ApiError
|
||||
, LoginResult
|
||||
)
|
||||
|
||||
|
||||
type alias LoginResult =
|
||||
{ token : String
|
||||
, username : String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias ApiError =
|
||||
{ code : String
|
||||
, message : String
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
module Types.Model exposing
|
||||
( AdminManualEntry
|
||||
, EditingTimeEntry
|
||||
, Flags
|
||||
, Model
|
||||
, NewSchedule
|
||||
, NewSchoolYear
|
||||
, NewUser
|
||||
, Schedule
|
||||
, SchoolYear
|
||||
, SelectedEntry
|
||||
, TimeEntry
|
||||
, Toast
|
||||
, ToastType(..)
|
||||
, User
|
||||
, WeekDates
|
||||
, WeeklyHours
|
||||
, WeeklySummary
|
||||
, YearlyHoursSummary
|
||||
)
|
||||
|
||||
import Time
|
||||
import Types.Page exposing (AdminTab, Page)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ page : Page
|
||||
, activeTab : AdminTab
|
||||
, username : String
|
||||
, password : String
|
||||
, token : Maybe String
|
||||
, isAdmin : Bool
|
||||
, schedules : List Schedule
|
||||
, users : List User
|
||||
, timeEntries : List TimeEntry
|
||||
, weeklyHours : List WeeklyHours
|
||||
, yearlyHoursSummary : List YearlyHoursSummary
|
||||
, selectedEntries : List SelectedEntry
|
||||
, currentWeek : Int
|
||||
, currentYear : Int
|
||||
, weekDates : Maybe WeekDates
|
||||
, currentTime : Time.Posix
|
||||
, zone : Time.Zone
|
||||
, newSchedule : NewSchedule
|
||||
, newUser : NewUser
|
||||
, error : Maybe String
|
||||
, weekEditMode : Bool
|
||||
, hasEntriesForCurrentWeek : Bool
|
||||
, userWeeklySummary : Maybe WeeklySummary
|
||||
, editingTimeEntryId : Maybe Int
|
||||
, editingTimeEntry : EditingTimeEntry
|
||||
, editingUserId : Maybe Int
|
||||
, editingUserWorkHours : String
|
||||
, resetPasswordUserId : Maybe Int
|
||||
, resetPasswordNew : String
|
||||
, pendingDeleteId : Maybe Int
|
||||
, selectedUserId : Maybe Int
|
||||
, userWorkHoursInput : String
|
||||
, userPasswordInput : String
|
||||
, isProcessing : Bool
|
||||
, mobileMenuOpen : Bool
|
||||
, adminManualEntryForm : AdminManualEntry
|
||||
, schoolYears : List SchoolYear
|
||||
, newSchoolYear : NewSchoolYear
|
||||
, activeSchoolYear : Maybe SchoolYear
|
||||
, editingSchoolYearId : Maybe Int
|
||||
, toasts : List Toast
|
||||
, nextToastId : Int
|
||||
}
|
||||
|
||||
|
||||
type ToastType
|
||||
= ErrorToast
|
||||
| SuccessToast
|
||||
| InfoToast
|
||||
| WarningToast
|
||||
|
||||
|
||||
type alias Toast =
|
||||
{ id : Int
|
||||
, message : String
|
||||
, toastType : ToastType
|
||||
, dismissible : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias Flags =
|
||||
{ token : Maybe String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias Schedule =
|
||||
{ id : Int
|
||||
, dayOfWeek : Int
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
, scheduleType : String
|
||||
, title : String
|
||||
}
|
||||
|
||||
|
||||
type alias User =
|
||||
{ id : Int
|
||||
, username : String
|
||||
, isAdmin : Bool
|
||||
, yearlyWorkHours : Float
|
||||
}
|
||||
|
||||
|
||||
type alias TimeEntry =
|
||||
{ id : Int
|
||||
, userId : Int
|
||||
, scheduleId : Int
|
||||
, date : String
|
||||
, entryType : String
|
||||
, username : String
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
}
|
||||
|
||||
|
||||
type alias SelectedEntry =
|
||||
{ scheduleId : Int
|
||||
, dayOfWeek : Int
|
||||
}
|
||||
|
||||
|
||||
type alias NewSchedule =
|
||||
{ dayOfWeek : String
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
, scheduleType : String
|
||||
, title : String
|
||||
}
|
||||
|
||||
|
||||
type alias NewUser =
|
||||
{ username : String
|
||||
, password : String
|
||||
, isAdmin : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias WeekDates =
|
||||
{ year : Int
|
||||
, week : Int
|
||||
, dates : List ( String, String )
|
||||
, range : String
|
||||
}
|
||||
|
||||
|
||||
type alias WeeklySummary =
|
||||
{ userId : Int
|
||||
, username : String
|
||||
, year : Int
|
||||
, week : Int
|
||||
, totalHours : Float
|
||||
, targetHours : Float
|
||||
, remainingHours : Float
|
||||
}
|
||||
|
||||
|
||||
type alias EditingTimeEntry =
|
||||
{ entryId : Int
|
||||
, date : String
|
||||
, startTime : String
|
||||
, endTime : String
|
||||
, entryType : String
|
||||
}
|
||||
|
||||
|
||||
type alias WeeklyHours =
|
||||
{ userId : Int
|
||||
, username : String
|
||||
, year : Int
|
||||
, week : Int
|
||||
, totalHours : Float
|
||||
, targetHours : Float
|
||||
, remainingHours : Float
|
||||
}
|
||||
|
||||
|
||||
type alias YearlyHoursSummary =
|
||||
{ userId : Int
|
||||
, username : String
|
||||
, year : Int
|
||||
, week : Int
|
||||
, totalHours : Float
|
||||
, yearlyTarget : Float
|
||||
, yearlyActual : Float
|
||||
, weeklyTarget : Float
|
||||
, remainingYearly : Float
|
||||
}
|
||||
|
||||
|
||||
type alias AdminManualEntry =
|
||||
{ selectedUserId : Maybe Int
|
||||
, date : String
|
||||
, hours : String
|
||||
, entryType : String
|
||||
}
|
||||
|
||||
|
||||
type alias SchoolYear =
|
||||
{ id : Int
|
||||
, name : String
|
||||
, startDate : String
|
||||
, endDate : String
|
||||
, isActive : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias NewSchoolYear =
|
||||
{ name : String
|
||||
, startDate : String
|
||||
, endDate : String
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
module Types.Msg exposing (Msg(..))
|
||||
|
||||
import Bytes exposing (Bytes)
|
||||
import Http
|
||||
import Time
|
||||
import Types.Api exposing (LoginResult)
|
||||
import Types.Model
|
||||
exposing
|
||||
( Schedule
|
||||
, SchoolYear
|
||||
, TimeEntry
|
||||
, ToastType(..)
|
||||
, User
|
||||
, WeekDates
|
||||
, WeeklyHours
|
||||
, WeeklySummary
|
||||
, YearlyHoursSummary
|
||||
)
|
||||
import Types.Page exposing (AdminTab)
|
||||
|
||||
|
||||
type Msg
|
||||
= UpdateUsername String
|
||||
| UpdatePassword String
|
||||
| Login
|
||||
| LoginResponse (Result Http.Error LoginResult)
|
||||
| Logout
|
||||
| SetTime Time.Posix
|
||||
| FetchSchedules
|
||||
| SchedulesReceived (Result Http.Error (List Schedule))
|
||||
| ToggleScheduleSelection Int Int
|
||||
| SaveTimeEntries
|
||||
| TimeEntriesSaved (Result Http.Error ())
|
||||
| PreviousWeek
|
||||
| NextWeek
|
||||
| EnableEditMode
|
||||
| DisableEditMode
|
||||
| DeleteWeekEntries
|
||||
| WeekEntriesDeleted (Result Http.Error ())
|
||||
| SwitchTab AdminTab
|
||||
| UpdateNewScheduleDay String
|
||||
| UpdateNewScheduleStart String
|
||||
| UpdateNewScheduleEnd String
|
||||
| UpdateNewScheduleType String
|
||||
| UpdateNewScheduleTitle String
|
||||
| CreateSchedule
|
||||
| ScheduleCreated (Result Http.Error ())
|
||||
| DeleteSchedule Int
|
||||
| ScheduleDeleted (Result Http.Error ())
|
||||
| UpdateNewUsername String
|
||||
| UpdateNewPassword String
|
||||
| UpdateNewUserAdmin Bool
|
||||
| CreateUser
|
||||
| UserCreated (Result Http.Error ())
|
||||
| DeleteUser Int
|
||||
| UserDeleted (Result Http.Error ())
|
||||
| FetchUsers
|
||||
| UsersReceived (Result Http.Error (List User))
|
||||
| FetchMyTimeEntries
|
||||
| MyTimeEntriesReceived (Result Http.Error (List TimeEntry))
|
||||
| FetchAllTimeEntries
|
||||
| AllTimeEntriesReceived (Result Http.Error (List TimeEntry))
|
||||
| FetchWeeklyHours
|
||||
| WeeklyHoursReceived (Result Http.Error (List WeeklyHours))
|
||||
| FetchYearlyHoursSummary
|
||||
| YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary))
|
||||
| FetchWeekDates
|
||||
| WeekDatesReceived (Result Http.Error WeekDates)
|
||||
| CheckWeekHasEntries
|
||||
| WeekHasEntriesReceived (Result Http.Error Bool)
|
||||
| MyWeeklySummaryReceived (Result Http.Error WeeklySummary)
|
||||
| EditTimeEntry Int
|
||||
| CancelEditTimeEntry
|
||||
| UpdateEditTimeEntryDate String
|
||||
| UpdateEditTimeEntryStartTime String
|
||||
| UpdateEditTimeEntryEndTime String
|
||||
| UpdateEditTimeEntryType String
|
||||
| SaveEditTimeEntry
|
||||
| TimeEntrySaved (Result Http.Error ())
|
||||
| TimeEntryDeleted (Result Http.Error ())
|
||||
| EditUserWorkHours Int
|
||||
| CancelEditUserWorkHours
|
||||
| UpdateEditUserWorkHours String
|
||||
| SaveUserWorkHours
|
||||
| UserWorkHoursSaved (Result Http.Error ())
|
||||
| ResetUserPassword Int
|
||||
| CancelResetPassword
|
||||
| UpdateResetPasswordNew String
|
||||
| SaveResetPassword
|
||||
| ResetPasswordSaved (Result Http.Error ())
|
||||
| ConfirmDeleteTimeEntry Int
|
||||
| ConfirmDeleteUser Int
|
||||
| DeleteConfirmed Bool
|
||||
| StartEditingTimeEntry Int TimeEntry
|
||||
| CancelEditingTimeEntry
|
||||
| UpdateEditingTimeEntryDate String
|
||||
| UpdateEditingTimeEntryStartTime String
|
||||
| UpdateEditingTimeEntryEndTime String
|
||||
| UpdateEditingTimeEntryType String
|
||||
| SaveEditingTimeEntry
|
||||
| SelectUserForManagement Int
|
||||
| UpdateUserWorkHours String
|
||||
| UpdateUserPassword String
|
||||
| SaveUserPassword
|
||||
| UserPasswordSaved (Result Http.Error ())
|
||||
| ToggleMobileMenu
|
||||
| CloseMobileMenu
|
||||
| SelectUserForManualEntry Int
|
||||
| UpdateManualEntryDate String
|
||||
| UpdateManualEntryHours String
|
||||
| UpdateManualEntryType String
|
||||
| SaveAdminTimeEntry
|
||||
| AdminTimeEntrySaved (Result Http.Error ())
|
||||
| FetchMyInfo
|
||||
| MyInfoReceived (Result Http.Error User)
|
||||
| FetchSchoolYears
|
||||
| SchoolYearsReceived (Result Http.Error (List SchoolYear))
|
||||
| FetchActiveSchoolYear
|
||||
| ActiveSchoolYearReceived (Result Http.Error SchoolYear)
|
||||
| UpdateNewSchoolYearName String
|
||||
| UpdateNewSchoolYearStart String
|
||||
| UpdateNewSchoolYearEnd String
|
||||
| CreateSchoolYear
|
||||
| SchoolYearCreated (Result Http.Error ())
|
||||
| ActivateSchoolYear Int
|
||||
| SchoolYearActivated (Result Http.Error ())
|
||||
| DeleteSchoolYear Int
|
||||
| SchoolYearDeleted (Result Http.Error ())
|
||||
| DownloadYearlySummaryPDF
|
||||
| YearlySummaryPDFReceived (Result Http.Error Bytes)
|
||||
| ShowToast String ToastType
|
||||
| DismissToast Int
|
||||
| AutoDismissToast Int
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
module Types.Page exposing
|
||||
( AdminTab(..)
|
||||
, Page(..)
|
||||
)
|
||||
|
||||
|
||||
type Page
|
||||
= LoginPage
|
||||
| UserDashboard
|
||||
| AdminDashboard
|
||||
|
||||
|
||||
type AdminTab
|
||||
= ScheduleTab
|
||||
| UsersTab
|
||||
| TimeEntriesTab
|
||||
| SchoolYearsTab
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
module Update.AuthUpdate exposing
|
||||
( handleLogin
|
||||
, handleLoginResponse
|
||||
, handleLogout
|
||||
)
|
||||
|
||||
import Api.Auth
|
||||
import Api.Schedule
|
||||
import Api.SchoolYear
|
||||
import Api.TimeEntry
|
||||
import Api.User
|
||||
import Http
|
||||
import Json.Encode as Encode
|
||||
import Task
|
||||
import Types.Model exposing (Model, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (Page(..))
|
||||
import Utils.DateUtils exposing (getISOWeekFromPosix)
|
||||
import Utils.Ports exposing (removeToken, saveToken)
|
||||
|
||||
|
||||
handleLogin : Model -> ( Model, Cmd Msg )
|
||||
handleLogin model =
|
||||
if model.isProcessing then
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password )
|
||||
|
||||
|
||||
handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg )
|
||||
handleLoginResponse result model =
|
||||
case result of
|
||||
Ok loginResult ->
|
||||
let
|
||||
newPage =
|
||||
if loginResult.isAdmin then
|
||||
AdminDashboard
|
||||
|
||||
else
|
||||
UserDashboard
|
||||
|
||||
( year, week ) =
|
||||
getISOWeekFromPosix model.currentTime
|
||||
|
||||
tokenData =
|
||||
Encode.object
|
||||
[ ( "token", Encode.string loginResult.token )
|
||||
, ( "isAdmin", Encode.bool loginResult.isAdmin )
|
||||
]
|
||||
in
|
||||
( { model
|
||||
| token = Just loginResult.token
|
||||
, username = loginResult.username
|
||||
, isAdmin = loginResult.isAdmin
|
||||
, page = newPage
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ saveToken tokenData
|
||||
, Api.Schedule.fetchSchedules (Just loginResult.token)
|
||||
, Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ())
|
||||
, if not loginResult.isAdmin then
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
|
||||
, Api.TimeEntry.fetchWeekDates loginResult.token year week
|
||||
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
|
||||
, Api.User.fetchMyInfo loginResult.token
|
||||
]
|
||||
|
||||
else
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
|
||||
, Api.TimeEntry.fetchWeekDates loginResult.token year week
|
||||
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
Err err ->
|
||||
let
|
||||
errorMsg =
|
||||
case err of
|
||||
Http.BadStatus 401 ->
|
||||
"Benutzername oder Passwort ungültig"
|
||||
|
||||
Http.Timeout ->
|
||||
"Zeitüberschreitung - bitte erneut versuchen"
|
||||
|
||||
Http.NetworkError ->
|
||||
"Netzwerkfehler - bitte Verbindung prüfen"
|
||||
|
||||
_ ->
|
||||
"Anmeldung fehlgeschlagen"
|
||||
in
|
||||
( { model | isProcessing = False }
|
||||
, Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ())
|
||||
)
|
||||
|
||||
|
||||
handleLogout : Model -> ( Model, Cmd Msg )
|
||||
handleLogout model =
|
||||
( { model
|
||||
| page = LoginPage
|
||||
, token = Nothing
|
||||
, isAdmin = False
|
||||
, username = ""
|
||||
, password = ""
|
||||
, isProcessing = False
|
||||
}
|
||||
, removeToken ()
|
||||
)
|
||||
|
|
@ -1,244 +0,0 @@
|
|||
module Update.ScheduleUpdate exposing
|
||||
( handleCreateSchedule
|
||||
, handleDeleteSchedule
|
||||
, handleDeleteWeekEntries
|
||||
, handleDisableEditMode
|
||||
, handleEnableEditMode
|
||||
, handleSaveTimeEntries
|
||||
, handleScheduleCreated
|
||||
, handleScheduleDeleted
|
||||
, handleSchedulesReceived
|
||||
, handleTimeEntriesSaved
|
||||
, handleToggleScheduleSelection
|
||||
, handleWeekEntriesDeleted
|
||||
)
|
||||
|
||||
import Api.Schedule
|
||||
import Api.TimeEntry
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate)
|
||||
|
||||
|
||||
handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg )
|
||||
handleToggleScheduleSelection scheduleId dayOfWeek model =
|
||||
let
|
||||
entry =
|
||||
{ scheduleId = scheduleId, dayOfWeek = dayOfWeek }
|
||||
|
||||
newSelected =
|
||||
if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then
|
||||
List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries
|
||||
|
||||
else
|
||||
entry :: model.selectedEntries
|
||||
in
|
||||
( { model | selectedEntries = newSelected }, Cmd.none )
|
||||
|
||||
|
||||
handleSaveTimeEntries : Model -> ( Model, Cmd Msg )
|
||||
handleSaveTimeEntries model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleTimeEntriesSaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| error = Nothing
|
||||
, weekEditMode = False
|
||||
, hasEntriesForCurrentWeek = True
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries token
|
||||
, Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEnableEditMode : Model -> ( Model, Cmd Msg )
|
||||
handleEnableEditMode model =
|
||||
let
|
||||
currentWeekEntries =
|
||||
List.filter
|
||||
(\e ->
|
||||
let
|
||||
( entryYear, entryWeek ) =
|
||||
getYearWeekFromDate e.date
|
||||
in
|
||||
entryWeek == model.currentWeek && entryYear == model.currentYear
|
||||
)
|
||||
model.timeEntries
|
||||
|
||||
preSelectedEntries =
|
||||
List.map
|
||||
(\entry ->
|
||||
let
|
||||
parts =
|
||||
String.split "-" entry.date
|
||||
|
||||
year =
|
||||
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
|
||||
|
||||
month =
|
||||
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
|
||||
day =
|
||||
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
|
||||
dayOfWeek =
|
||||
getDayOfWeek year month day
|
||||
in
|
||||
{ scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek }
|
||||
)
|
||||
currentWeekEntries
|
||||
in
|
||||
( { model
|
||||
| weekEditMode = True
|
||||
, selectedEntries = preSelectedEntries
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
handleDisableEditMode : Model -> ( Model, Cmd Msg )
|
||||
handleDisableEditMode model =
|
||||
( { model | weekEditMode = False }, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteWeekEntries : Model -> ( Model, Cmd Msg )
|
||||
handleDeleteWeekEntries model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleWeekEntriesDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| weekEditMode = True
|
||||
, selectedEntries = []
|
||||
, hasEntriesForCurrentWeek = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchMyTimeEntries token
|
||||
, Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleCreateSchedule : Model -> ( Model, Cmd Msg )
|
||||
handleCreateSchedule model =
|
||||
if
|
||||
String.isEmpty model.newSchedule.dayOfWeek
|
||||
|| String.isEmpty model.newSchedule.startTime
|
||||
|| String.isEmpty model.newSchedule.endTime
|
||||
then
|
||||
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
|
||||
|
||||
else
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleScheduleCreated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
let
|
||||
emptySchedule =
|
||||
NewSchedule "" "" "" "lesson" ""
|
||||
in
|
||||
( { model
|
||||
| newSchedule = emptySchedule
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.Schedule.fetchSchedules model.token
|
||||
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleDeleteSchedule scheduleId model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.Schedule.deleteSchedule token scheduleId )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleScheduleDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Cmd.batch
|
||||
[ Api.Schedule.fetchSchedules (Just token)
|
||||
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg )
|
||||
handleSchedulesReceived result model =
|
||||
case result of
|
||||
Ok schedules ->
|
||||
( { model | schedules = schedules }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
module Update.SchoolYearUpdate exposing
|
||||
( handleActivateSchoolYear
|
||||
, handleActiveSchoolYearReceived
|
||||
, handleCreateSchoolYear
|
||||
, handleDeleteSchoolYear
|
||||
, handleSchoolYearActivated
|
||||
, handleSchoolYearCreated
|
||||
, handleSchoolYearDeleted
|
||||
, handleSchoolYearsReceived
|
||||
)
|
||||
|
||||
import Api.SchoolYear
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
handleCreateSchoolYear : Model -> ( Model, Cmd Msg )
|
||||
handleCreateSchoolYear model =
|
||||
if
|
||||
String.isEmpty model.newSchoolYear.name
|
||||
|| String.isEmpty model.newSchoolYear.startDate
|
||||
|| String.isEmpty model.newSchoolYear.endDate
|
||||
then
|
||||
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
|
||||
|
||||
else
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearCreated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| newSchoolYear = NewSchoolYear "" "" ""
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
|
||||
handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleActivateSchoolYear id model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.activateSchoolYear token id )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearActivated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Api.SchoolYear.fetchActiveSchoolYear token
|
||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleDeleteSchoolYear id model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.deleteSchoolYear token id )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | error = Nothing }
|
||||
, Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg )
|
||||
handleSchoolYearsReceived result model =
|
||||
case result of
|
||||
Ok years ->
|
||||
( { model | schoolYears = years }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg )
|
||||
handleActiveSchoolYearReceived result model =
|
||||
case result of
|
||||
Ok year ->
|
||||
( { model | activeSchoolYear = Just year }, Cmd.none )
|
||||
|
||||
Err _ ->
|
||||
( { model | activeSchoolYear = Nothing }, Cmd.none )
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
module Update.TimeEntryUpdate exposing
|
||||
( handleAdminTimeEntrySaved
|
||||
, handleAllTimeEntriesReceived
|
||||
, handleConfirmDeleteTimeEntry
|
||||
, handleEditTimeEntry
|
||||
, handleMyTimeEntriesReceived
|
||||
, handleSaveAdminTimeEntry
|
||||
, handleSaveEditTimeEntry
|
||||
, handleTimeEntryDeleted
|
||||
, handleTimeEntrySaved
|
||||
, handleYearlyHoursSummaryReceived
|
||||
)
|
||||
|
||||
import Api.TimeEntry
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.DateUtils exposing (getYearWeekFromDate)
|
||||
import Utils.Ports exposing (confirmDelete)
|
||||
|
||||
|
||||
handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
|
||||
handleMyTimeEntriesReceived result model =
|
||||
case result of
|
||||
Ok entries ->
|
||||
let
|
||||
hasEntries =
|
||||
List.any
|
||||
(\e ->
|
||||
let
|
||||
( entryYear, entryWeek ) =
|
||||
getYearWeekFromDate e.date
|
||||
in
|
||||
entryWeek == model.currentWeek && entryYear == model.currentYear
|
||||
)
|
||||
entries
|
||||
in
|
||||
( { model
|
||||
| timeEntries = entries
|
||||
, hasEntriesForCurrentWeek = hasEntries
|
||||
, weekEditMode = False
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
|
||||
handleAllTimeEntriesReceived result model =
|
||||
case result of
|
||||
Ok entries ->
|
||||
( { model | timeEntries = entries }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleEditTimeEntry entryId model =
|
||||
case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of
|
||||
Just entry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Just entryId
|
||||
, editingTimeEntry =
|
||||
{ entryId = entryId
|
||||
, date = entry.date
|
||||
, startTime = entry.startTime
|
||||
, endTime = entry.endTime
|
||||
, entryType = entry.entryType
|
||||
}
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg )
|
||||
handleSaveEditTimeEntry model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleTimeEntrySaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, pendingDeleteId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleTimeEntryDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
|
||||
, pendingDeleteId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
||||
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
||||
|
||||
|
||||
handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleConfirmDeleteTimeEntry entryId model =
|
||||
( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" )
|
||||
|
||||
|
||||
handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg )
|
||||
handleSaveAdminTimeEntry model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleAdminTimeEntrySaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
|
||||
, error = Nothing
|
||||
, isProcessing = False
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
||||
, Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
|
||||
handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg )
|
||||
handleYearlyHoursSummaryReceived result model =
|
||||
case result of
|
||||
Ok summary ->
|
||||
( { model | yearlyHoursSummary = summary }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
|
@ -1,811 +0,0 @@
|
|||
module Update.Update exposing (update)
|
||||
|
||||
import Api.Schedule
|
||||
import Api.SchoolYear
|
||||
import Api.TimeEntry
|
||||
import Api.User
|
||||
import File.Download
|
||||
import Process
|
||||
import Task
|
||||
import Time
|
||||
import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (AdminTab(..), Page(..))
|
||||
import Update.AuthUpdate as Auth
|
||||
import Update.ScheduleUpdate as Schedule
|
||||
import Update.SchoolYearUpdate as SchoolYear
|
||||
import Update.TimeEntryUpdate as TimeEntry
|
||||
import Update.UserUpdate as User
|
||||
import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek)
|
||||
import Utils.Ports
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
-- Mobile Menu
|
||||
ToggleMobileMenu ->
|
||||
( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none )
|
||||
|
||||
CloseMobileMenu ->
|
||||
( { model | mobileMenuOpen = False }, Cmd.none )
|
||||
|
||||
-- Auth
|
||||
UpdateUsername username ->
|
||||
( { model | username = username }, Cmd.none )
|
||||
|
||||
UpdatePassword password ->
|
||||
( { model | password = password }, Cmd.none )
|
||||
|
||||
Login ->
|
||||
Auth.handleLogin model
|
||||
|
||||
LoginResponse result ->
|
||||
Auth.handleLoginResponse result model
|
||||
|
||||
Logout ->
|
||||
Auth.handleLogout model
|
||||
|
||||
-- Time
|
||||
SetTime time ->
|
||||
let
|
||||
( year, week ) =
|
||||
getISOWeekFromPosix time
|
||||
|
||||
cmds =
|
||||
case model.token of
|
||||
Just token ->
|
||||
if model.page == UserDashboard || model.page == LoginPage then
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.checkWeekHasEntries token year week
|
||||
, Api.TimeEntry.fetchWeekDates token year week
|
||||
, Api.TimeEntry.fetchMyTimeEntries token
|
||||
]
|
||||
|
||||
else
|
||||
Cmd.none
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
in
|
||||
( { model
|
||||
| currentTime = time
|
||||
, currentWeek = week
|
||||
, currentYear = year
|
||||
}
|
||||
, cmds
|
||||
)
|
||||
|
||||
-- Schedules
|
||||
FetchSchedules ->
|
||||
( model, Api.Schedule.fetchSchedules model.token )
|
||||
|
||||
SchedulesReceived result ->
|
||||
Schedule.handleSchedulesReceived result model
|
||||
|
||||
ToggleScheduleSelection scheduleId dayOfWeek ->
|
||||
Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model
|
||||
|
||||
SaveTimeEntries ->
|
||||
Schedule.handleSaveTimeEntries model
|
||||
|
||||
TimeEntriesSaved result ->
|
||||
Schedule.handleTimeEntriesSaved result model
|
||||
|
||||
EnableEditMode ->
|
||||
Schedule.handleEnableEditMode model
|
||||
|
||||
DisableEditMode ->
|
||||
Schedule.handleDisableEditMode model
|
||||
|
||||
DeleteWeekEntries ->
|
||||
Schedule.handleDeleteWeekEntries model
|
||||
|
||||
WeekEntriesDeleted result ->
|
||||
Schedule.handleWeekEntriesDeleted result model
|
||||
|
||||
CreateSchedule ->
|
||||
Schedule.handleCreateSchedule model
|
||||
|
||||
ScheduleCreated result ->
|
||||
Schedule.handleScheduleCreated result model
|
||||
|
||||
DeleteSchedule scheduleId ->
|
||||
Schedule.handleDeleteSchedule scheduleId model
|
||||
|
||||
ScheduleDeleted result ->
|
||||
Schedule.handleScheduleDeleted result model
|
||||
|
||||
-- Week Navigation
|
||||
PreviousWeek ->
|
||||
let
|
||||
( newYear, newWeek ) =
|
||||
previousWeek model.currentYear model.currentWeek
|
||||
in
|
||||
( { model
|
||||
| currentWeek = newWeek
|
||||
, currentYear = newYear
|
||||
, selectedEntries = []
|
||||
, weekEditMode = False
|
||||
}
|
||||
, case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
|
||||
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
)
|
||||
|
||||
NextWeek ->
|
||||
let
|
||||
( newYear, newWeek ) =
|
||||
nextWeek model.currentYear model.currentWeek
|
||||
in
|
||||
( { model
|
||||
| currentWeek = newWeek
|
||||
, currentYear = newYear
|
||||
, selectedEntries = []
|
||||
, weekEditMode = False
|
||||
}
|
||||
, case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
|
||||
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
)
|
||||
|
||||
FetchWeekDates ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
WeekDatesReceived result ->
|
||||
case result of
|
||||
Ok weekDates ->
|
||||
( { model | weekDates = Just weekDates }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
CheckWeekHasEntries ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
WeekHasEntriesReceived result ->
|
||||
case result of
|
||||
Ok hasEntries ->
|
||||
( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
-- Admin Tabs
|
||||
SwitchTab tab ->
|
||||
let
|
||||
cmd =
|
||||
case tab of
|
||||
UsersTab ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
Api.User.fetchUsers token
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
TimeEntriesTab ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
SchoolYearsTab ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
Cmd.batch
|
||||
[ Api.SchoolYear.fetchSchoolYears token
|
||||
, Api.SchoolYear.fetchActiveSchoolYear token
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
|
||||
_ ->
|
||||
Cmd.none
|
||||
in
|
||||
( { model | activeTab = tab, mobileMenuOpen = False }, cmd )
|
||||
|
||||
-- Schedule Form
|
||||
UpdateNewScheduleDay day ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | dayOfWeek = day }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleStart time ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | startTime = time }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleEnd time ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | endTime = time }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleType scheduleType ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | scheduleType = scheduleType }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
UpdateNewScheduleTitle title ->
|
||||
let
|
||||
oldSchedule =
|
||||
model.newSchedule
|
||||
|
||||
newSchedule =
|
||||
{ oldSchedule | title = title }
|
||||
in
|
||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
||||
|
||||
-- Users
|
||||
UpdateNewUsername username ->
|
||||
let
|
||||
oldUser =
|
||||
model.newUser
|
||||
|
||||
newUser =
|
||||
{ oldUser | username = username }
|
||||
in
|
||||
( { model | newUser = newUser }, Cmd.none )
|
||||
|
||||
UpdateNewPassword password ->
|
||||
let
|
||||
oldUser =
|
||||
model.newUser
|
||||
|
||||
newUser =
|
||||
{ oldUser | password = password }
|
||||
in
|
||||
( { model | newUser = newUser }, Cmd.none )
|
||||
|
||||
UpdateNewUserAdmin isAdmin ->
|
||||
let
|
||||
oldUser =
|
||||
model.newUser
|
||||
|
||||
newUser =
|
||||
{ oldUser | isAdmin = isAdmin }
|
||||
in
|
||||
( { model | newUser = newUser }, Cmd.none )
|
||||
|
||||
CreateUser ->
|
||||
User.handleCreateUser model
|
||||
|
||||
UserCreated result ->
|
||||
User.handleUserCreated result model
|
||||
|
||||
DeleteUser userId ->
|
||||
User.handleDeleteUser userId model
|
||||
|
||||
UserDeleted result ->
|
||||
User.handleUserDeleted result model
|
||||
|
||||
FetchUsers ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.fetchUsers token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
UsersReceived result ->
|
||||
User.handleUsersReceived result model
|
||||
|
||||
EditUserWorkHours userId ->
|
||||
User.handleEditUserWorkHours userId model
|
||||
|
||||
CancelEditUserWorkHours ->
|
||||
( { model
|
||||
| editingUserId = Nothing
|
||||
, editingUserWorkHours = ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateEditUserWorkHours hours ->
|
||||
( { model | editingUserWorkHours = hours }, Cmd.none )
|
||||
|
||||
SaveUserWorkHours ->
|
||||
User.handleSaveUserWorkHours model
|
||||
|
||||
UserWorkHoursSaved result ->
|
||||
User.handleUserWorkHoursSaved result model
|
||||
|
||||
ResetUserPassword userId ->
|
||||
User.handleResetUserPassword userId model
|
||||
|
||||
CancelResetPassword ->
|
||||
( { model
|
||||
| resetPasswordUserId = Nothing
|
||||
, resetPasswordNew = ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateResetPasswordNew password ->
|
||||
( { model | resetPasswordNew = password }, Cmd.none )
|
||||
|
||||
SaveResetPassword ->
|
||||
User.handleSaveResetPassword model
|
||||
|
||||
ResetPasswordSaved result ->
|
||||
User.handleResetPasswordSaved result model
|
||||
|
||||
UpdateUserWorkHours input ->
|
||||
( { model | userWorkHoursInput = input }, Cmd.none )
|
||||
|
||||
UpdateUserPassword input ->
|
||||
( { model | userPasswordInput = input }, Cmd.none )
|
||||
|
||||
SaveUserPassword ->
|
||||
case ( model.token, model.selectedUserId ) of
|
||||
( Just token, Just userId ) ->
|
||||
if String.length model.userPasswordInput > 0 then
|
||||
( model, Api.User.resetUserPassword token userId model.userPasswordInput )
|
||||
|
||||
else
|
||||
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
|
||||
|
||||
_ ->
|
||||
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
|
||||
|
||||
UserPasswordSaved result ->
|
||||
case result of
|
||||
Ok _ ->
|
||||
( { model
|
||||
| userPasswordInput = ""
|
||||
, selectedUserId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ())
|
||||
)
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SelectUserForManagement userId ->
|
||||
( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none )
|
||||
|
||||
-- Time Entries
|
||||
FetchMyTimeEntries ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchMyTimeEntries token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MyTimeEntriesReceived result ->
|
||||
TimeEntry.handleMyTimeEntriesReceived result model
|
||||
|
||||
FetchAllTimeEntries ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchAllTimeEntries token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
AllTimeEntriesReceived result ->
|
||||
TimeEntry.handleAllTimeEntriesReceived result model
|
||||
|
||||
EditTimeEntry entryId ->
|
||||
TimeEntry.handleEditTimeEntry entryId model
|
||||
|
||||
CancelEditTimeEntry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateEditTimeEntryDate date ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | date = date }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditTimeEntryStartTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | startTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditTimeEntryEndTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | endTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditTimeEntryType entryType ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | entryType = entryType }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
SaveEditTimeEntry ->
|
||||
TimeEntry.handleSaveEditTimeEntry model
|
||||
|
||||
TimeEntrySaved result ->
|
||||
TimeEntry.handleTimeEntrySaved result model
|
||||
|
||||
TimeEntryDeleted result ->
|
||||
TimeEntry.handleTimeEntryDeleted result model
|
||||
|
||||
ConfirmDeleteTimeEntry entryId ->
|
||||
TimeEntry.handleConfirmDeleteTimeEntry entryId model
|
||||
|
||||
StartEditingTimeEntry entryId entry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Just entryId
|
||||
, editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
CancelEditingTimeEntry ->
|
||||
( { model
|
||||
| editingTimeEntryId = Nothing
|
||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
UpdateEditingTimeEntryDate date ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | date = date }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditingTimeEntryStartTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | startTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditingTimeEntryEndTime time ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | endTime = time }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
UpdateEditingTimeEntryType entryType ->
|
||||
let
|
||||
old =
|
||||
model.editingTimeEntry
|
||||
|
||||
new =
|
||||
{ old | entryType = entryType }
|
||||
in
|
||||
( { model | editingTimeEntry = new }, Cmd.none )
|
||||
|
||||
SaveEditingTimeEntry ->
|
||||
case ( model.token, model.editingTimeEntryId ) of
|
||||
( Just token, Just entryId ) ->
|
||||
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
-- Weekly Hours
|
||||
FetchWeeklyHours ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
WeeklyHoursReceived result ->
|
||||
case result of
|
||||
Ok hours ->
|
||||
( { model | weeklyHours = hours }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MyWeeklySummaryReceived result ->
|
||||
case result of
|
||||
Ok summary ->
|
||||
( { model | userWeeklySummary = Just summary }, Cmd.none )
|
||||
|
||||
Err _ ->
|
||||
( { model | userWeeklySummary = Nothing }, Cmd.none )
|
||||
|
||||
-- Yearly Hours
|
||||
FetchYearlyHoursSummary ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.TimeEntry.fetchYearlyHoursSummary token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
YearlyHoursSummaryReceived result ->
|
||||
TimeEntry.handleYearlyHoursSummaryReceived result model
|
||||
|
||||
-- Admin Manual Entry
|
||||
SelectUserForManualEntry userId ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none )
|
||||
|
||||
UpdateManualEntryDate date ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | date = date } }, Cmd.none )
|
||||
|
||||
UpdateManualEntryHours hours ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none )
|
||||
|
||||
UpdateManualEntryType entryType ->
|
||||
let
|
||||
form =
|
||||
model.adminManualEntryForm
|
||||
in
|
||||
( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none )
|
||||
|
||||
SaveAdminTimeEntry ->
|
||||
TimeEntry.handleSaveAdminTimeEntry model
|
||||
|
||||
AdminTimeEntrySaved result ->
|
||||
TimeEntry.handleAdminTimeEntrySaved result model
|
||||
|
||||
-- My Info
|
||||
FetchMyInfo ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.fetchMyInfo token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
MyInfoReceived result ->
|
||||
case result of
|
||||
Ok user ->
|
||||
( { model | users = [ user ] }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
-- School Years
|
||||
FetchSchoolYears ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.fetchSchoolYears token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SchoolYearsReceived result ->
|
||||
SchoolYear.handleSchoolYearsReceived result model
|
||||
|
||||
FetchActiveSchoolYear ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.SchoolYear.fetchActiveSchoolYear token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ActiveSchoolYearReceived result ->
|
||||
SchoolYear.handleActiveSchoolYearReceived result model
|
||||
|
||||
UpdateNewSchoolYearName name ->
|
||||
let
|
||||
old =
|
||||
model.newSchoolYear
|
||||
|
||||
new =
|
||||
{ old | name = name }
|
||||
in
|
||||
( { model | newSchoolYear = new }, Cmd.none )
|
||||
|
||||
UpdateNewSchoolYearStart date ->
|
||||
let
|
||||
old =
|
||||
model.newSchoolYear
|
||||
|
||||
new =
|
||||
{ old | startDate = date }
|
||||
in
|
||||
( { model | newSchoolYear = new }, Cmd.none )
|
||||
|
||||
UpdateNewSchoolYearEnd date ->
|
||||
let
|
||||
old =
|
||||
model.newSchoolYear
|
||||
|
||||
new =
|
||||
{ old | endDate = date }
|
||||
in
|
||||
( { model | newSchoolYear = new }, Cmd.none )
|
||||
|
||||
CreateSchoolYear ->
|
||||
SchoolYear.handleCreateSchoolYear model
|
||||
|
||||
SchoolYearCreated result ->
|
||||
SchoolYear.handleSchoolYearCreated result model
|
||||
|
||||
ActivateSchoolYear id ->
|
||||
SchoolYear.handleActivateSchoolYear id model
|
||||
|
||||
SchoolYearActivated result ->
|
||||
SchoolYear.handleSchoolYearActivated result model
|
||||
|
||||
DeleteSchoolYear id ->
|
||||
SchoolYear.handleDeleteSchoolYear id model
|
||||
|
||||
SchoolYearDeleted result ->
|
||||
SchoolYear.handleSchoolYearDeleted result model
|
||||
|
||||
-- PDF Download
|
||||
DownloadYearlySummaryPDF ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
YearlySummaryPDFReceived result ->
|
||||
case result of
|
||||
Ok pdfBytes ->
|
||||
let
|
||||
filename =
|
||||
"Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf"
|
||||
in
|
||||
( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
|
||||
|
||||
Err err ->
|
||||
( { model | isProcessing = False }, Cmd.none )
|
||||
|
||||
-- Delete Confirmation
|
||||
ConfirmDeleteUser userId ->
|
||||
( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" )
|
||||
|
||||
DeleteConfirmed confirmed ->
|
||||
if confirmed then
|
||||
case ( model.token, model.pendingDeleteId ) of
|
||||
( Just token, Just id ) ->
|
||||
let
|
||||
isTimeEntry =
|
||||
List.any (\e -> e.id == id) model.timeEntries
|
||||
in
|
||||
if isTimeEntry then
|
||||
( model, Api.TimeEntry.deleteTimeEntry token id )
|
||||
|
||||
else
|
||||
( model, Api.User.deleteUser token id )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
else
|
||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
||||
|
||||
-- Toasts
|
||||
ShowToast message toastType ->
|
||||
let
|
||||
newToast =
|
||||
{ id = model.nextToastId
|
||||
, message = message
|
||||
, toastType = toastType
|
||||
, dismissible = True
|
||||
}
|
||||
|
||||
dismissDelay =
|
||||
case toastType of
|
||||
ErrorToast ->
|
||||
8000
|
||||
|
||||
SuccessToast ->
|
||||
5000
|
||||
|
||||
InfoToast ->
|
||||
5000
|
||||
|
||||
WarningToast ->
|
||||
6000
|
||||
in
|
||||
( { model
|
||||
| toasts = model.toasts ++ [ newToast ]
|
||||
, nextToastId = model.nextToastId + 1
|
||||
}
|
||||
, Task.perform (\_ -> AutoDismissToast newToast.id)
|
||||
(Process.sleep dismissDelay)
|
||||
)
|
||||
|
||||
DismissToast toastId ->
|
||||
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
AutoDismissToast toastId ->
|
||||
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
|
||||
, Cmd.none
|
||||
)
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
module Update.UserUpdate exposing
|
||||
( handleCreateUser
|
||||
, handleDeleteUser
|
||||
, handleEditUserWorkHours
|
||||
, handleResetPasswordSaved
|
||||
, handleResetUserPassword
|
||||
, handleSaveResetPassword
|
||||
, handleSaveUserWorkHours
|
||||
, handleUserCreated
|
||||
, handleUserDeleted
|
||||
, handleUserWorkHoursSaved
|
||||
, handleUsersReceived
|
||||
)
|
||||
|
||||
import Api.User
|
||||
import Http
|
||||
import Task
|
||||
import Types.Model exposing (Model, NewUser, ToastType(..), User)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
handleCreateUser : Model -> ( Model, Cmd Msg )
|
||||
handleCreateUser model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.createUser token model.newUser )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleUserCreated result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
let
|
||||
emptyUser =
|
||||
NewUser "" "" False
|
||||
in
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model | newUser = emptyUser }
|
||||
, Cmd.batch
|
||||
[ Api.User.fetchUsers token
|
||||
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleDeleteUser : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleDeleteUser userId model =
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.deleteUser token userId )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleUserDeleted result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| pendingDeleteId = Nothing
|
||||
, error = Nothing
|
||||
, editingUserId = Nothing
|
||||
, resetPasswordUserId = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.User.fetchUsers token
|
||||
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
||||
|
||||
|
||||
handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg )
|
||||
handleUsersReceived result model =
|
||||
case result of
|
||||
Ok users ->
|
||||
( { model | users = users }, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleEditUserWorkHours userId model =
|
||||
case List.filter (\u -> u.id == userId) model.users |> List.head of
|
||||
Just user ->
|
||||
( { model
|
||||
| editingUserId = Just userId
|
||||
, editingUserWorkHours = String.fromFloat user.yearlyWorkHours
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleSaveUserWorkHours : Model -> ( Model, Cmd Msg )
|
||||
handleSaveUserWorkHours model =
|
||||
case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of
|
||||
( Just token, Just userId, Just hours ) ->
|
||||
( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) )
|
||||
|
||||
_ ->
|
||||
( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) )
|
||||
|
||||
|
||||
handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleUserWorkHoursSaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( { model
|
||||
| editingUserWorkHours = ""
|
||||
, editingUserId = Nothing
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ Api.User.fetchUsers token
|
||||
, Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg )
|
||||
handleResetUserPassword userId model =
|
||||
( { model
|
||||
| resetPasswordUserId = Just userId
|
||||
, resetPasswordNew = ""
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
|
||||
handleSaveResetPassword : Model -> ( Model, Cmd Msg )
|
||||
handleSaveResetPassword model =
|
||||
case model.resetPasswordUserId of
|
||||
Just userId ->
|
||||
case model.token of
|
||||
Just token ->
|
||||
( model, Api.User.resetUserPassword token userId model.resetPasswordNew )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
||||
handleResetPasswordSaved result model =
|
||||
case result of
|
||||
Ok _ ->
|
||||
( { model
|
||||
| resetPasswordUserId = Nothing
|
||||
, resetPasswordNew = ""
|
||||
, error = Nothing
|
||||
}
|
||||
, Cmd.batch
|
||||
[ case model.token of
|
||||
Just token ->
|
||||
Api.User.fetchUsers token
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ())
|
||||
]
|
||||
)
|
||||
|
||||
Err err ->
|
||||
( model, Cmd.none )
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
module Utils.DateUtils exposing
|
||||
( addDaysToDate
|
||||
, getDateForWeekDay
|
||||
, getDayOfWeek
|
||||
, getDayOfYear
|
||||
, getISOWeek
|
||||
, getISOWeekFromPosix
|
||||
, getWeekDateRange
|
||||
, getYearWeekFromDate
|
||||
, isLeapYear
|
||||
, monthToInt
|
||||
, nextWeek
|
||||
, previousWeek
|
||||
)
|
||||
|
||||
import Time
|
||||
|
||||
|
||||
getISOWeekFromPosix : Time.Posix -> ( Int, Int )
|
||||
getISOWeekFromPosix time =
|
||||
let
|
||||
year =
|
||||
Time.toYear Time.utc time
|
||||
|
||||
month =
|
||||
Time.toMonth Time.utc time |> monthToInt
|
||||
|
||||
day =
|
||||
Time.toDay Time.utc time
|
||||
in
|
||||
( year, getISOWeek year month day )
|
||||
|
||||
|
||||
monthToInt : Time.Month -> Int
|
||||
monthToInt month =
|
||||
case month of
|
||||
Time.Jan ->
|
||||
1
|
||||
|
||||
Time.Feb ->
|
||||
2
|
||||
|
||||
Time.Mar ->
|
||||
3
|
||||
|
||||
Time.Apr ->
|
||||
4
|
||||
|
||||
Time.May ->
|
||||
5
|
||||
|
||||
Time.Jun ->
|
||||
6
|
||||
|
||||
Time.Jul ->
|
||||
7
|
||||
|
||||
Time.Aug ->
|
||||
8
|
||||
|
||||
Time.Sep ->
|
||||
9
|
||||
|
||||
Time.Oct ->
|
||||
10
|
||||
|
||||
Time.Nov ->
|
||||
11
|
||||
|
||||
Time.Dec ->
|
||||
12
|
||||
|
||||
|
||||
getISOWeek : Int -> Int -> Int -> Int
|
||||
getISOWeek year month day =
|
||||
let
|
||||
dayOfYear =
|
||||
getDayOfYear year month day
|
||||
|
||||
jan4DayOfWeek =
|
||||
getDayOfWeek year 1 4
|
||||
|
||||
mondayOfWeek1DayOfYear =
|
||||
4 - jan4DayOfWeek
|
||||
|
||||
weekNum =
|
||||
((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1
|
||||
in
|
||||
if weekNum < 1 then
|
||||
52
|
||||
|
||||
else if weekNum > 52 then
|
||||
let
|
||||
dec31DayOfWeek =
|
||||
getDayOfWeek year 12 31
|
||||
|
||||
jan1DayOfWeek =
|
||||
getDayOfWeek year 1 1
|
||||
in
|
||||
if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then
|
||||
weekNum
|
||||
|
||||
else
|
||||
1
|
||||
|
||||
else
|
||||
weekNum
|
||||
|
||||
|
||||
getDayOfYear : Int -> Int -> Int -> Int
|
||||
getDayOfYear year month day =
|
||||
let
|
||||
daysInMonth =
|
||||
[ 31
|
||||
, if isLeapYear year then
|
||||
29
|
||||
|
||||
else
|
||||
28
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
, 30
|
||||
, 31
|
||||
]
|
||||
|
||||
daysBefore =
|
||||
List.take (month - 1) daysInMonth |> List.sum
|
||||
in
|
||||
daysBefore + day
|
||||
|
||||
|
||||
isLeapYear : Int -> Bool
|
||||
isLeapYear year =
|
||||
(modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0))
|
||||
|
||||
|
||||
getDayOfWeek : Int -> Int -> Int -> Int
|
||||
getDayOfWeek year month day =
|
||||
let
|
||||
adjustedMonth =
|
||||
if month < 3 then
|
||||
month + 12
|
||||
|
||||
else
|
||||
month
|
||||
|
||||
adjustedYear =
|
||||
if month < 3 then
|
||||
year - 1
|
||||
|
||||
else
|
||||
year
|
||||
|
||||
q =
|
||||
day
|
||||
|
||||
m =
|
||||
adjustedMonth
|
||||
|
||||
k =
|
||||
modBy 100 adjustedYear
|
||||
|
||||
j =
|
||||
adjustedYear // 100
|
||||
|
||||
h =
|
||||
(q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7
|
||||
in
|
||||
(h + 5) |> modBy 7
|
||||
|
||||
|
||||
getDateForWeekDay : Int -> Int -> Int -> String
|
||||
getDateForWeekDay year week dayOfWeek =
|
||||
let
|
||||
jan4DayOfWeek =
|
||||
getDayOfWeek year 1 4
|
||||
|
||||
mondayOfWeek1Date =
|
||||
4 - jan4DayOfWeek
|
||||
|
||||
targetDayOfYear =
|
||||
mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek
|
||||
|
||||
( finalYear, finalMonth, finalDay ) =
|
||||
if targetDayOfYear < 1 then
|
||||
addDaysToDate (year - 1) 12 31 targetDayOfYear
|
||||
|
||||
else
|
||||
addDaysToDate year 1 targetDayOfYear 0
|
||||
in
|
||||
String.fromInt finalYear
|
||||
++ "-"
|
||||
++ String.padLeft 2 '0' (String.fromInt finalMonth)
|
||||
++ "-"
|
||||
++ String.padLeft 2 '0' (String.fromInt finalDay)
|
||||
|
||||
|
||||
addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int )
|
||||
addDaysToDate startYear startMonth startDay daysToAdd =
|
||||
let
|
||||
daysInMonth m y =
|
||||
case m of
|
||||
1 ->
|
||||
31
|
||||
|
||||
2 ->
|
||||
if isLeapYear y then
|
||||
29
|
||||
|
||||
else
|
||||
28
|
||||
|
||||
3 ->
|
||||
31
|
||||
|
||||
4 ->
|
||||
30
|
||||
|
||||
5 ->
|
||||
31
|
||||
|
||||
6 ->
|
||||
30
|
||||
|
||||
7 ->
|
||||
31
|
||||
|
||||
8 ->
|
||||
31
|
||||
|
||||
9 ->
|
||||
30
|
||||
|
||||
10 ->
|
||||
31
|
||||
|
||||
11 ->
|
||||
30
|
||||
|
||||
12 ->
|
||||
31
|
||||
|
||||
_ ->
|
||||
0
|
||||
|
||||
helper y m d remaining =
|
||||
if remaining == 0 then
|
||||
( y, m, d )
|
||||
|
||||
else if remaining > 0 then
|
||||
let
|
||||
daysInCurrentMonth =
|
||||
daysInMonth m y
|
||||
|
||||
daysLeftInMonth =
|
||||
daysInCurrentMonth - d
|
||||
in
|
||||
if remaining <= daysLeftInMonth then
|
||||
( y, m, d + remaining )
|
||||
|
||||
else if m == 12 then
|
||||
helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1)
|
||||
|
||||
else
|
||||
helper y (m + 1) 1 (remaining - daysLeftInMonth - 1)
|
||||
|
||||
else if d + remaining >= 1 then
|
||||
( y, m, d + remaining )
|
||||
|
||||
else if m == 1 then
|
||||
let
|
||||
prevMonthDays =
|
||||
daysInMonth 12 (y - 1)
|
||||
in
|
||||
helper (y - 1) 12 prevMonthDays (remaining + d)
|
||||
|
||||
else
|
||||
let
|
||||
prevMonthDays =
|
||||
daysInMonth (m - 1) y
|
||||
in
|
||||
helper y (m - 1) prevMonthDays (remaining + d)
|
||||
in
|
||||
helper startYear startMonth startDay daysToAdd
|
||||
|
||||
|
||||
previousWeek : Int -> Int -> ( Int, Int )
|
||||
previousWeek year week =
|
||||
if week == 1 then
|
||||
( year - 1, 52 )
|
||||
|
||||
else
|
||||
( year, week - 1 )
|
||||
|
||||
|
||||
nextWeek : Int -> Int -> ( Int, Int )
|
||||
nextWeek year week =
|
||||
if week >= 52 then
|
||||
( year + 1, 1 )
|
||||
|
||||
else
|
||||
( year, week + 1 )
|
||||
|
||||
|
||||
getWeekDateRange : Int -> Int -> String
|
||||
getWeekDateRange year week =
|
||||
let
|
||||
mondayDate =
|
||||
getDateForWeekDay year week 0
|
||||
|
||||
fridayDate =
|
||||
getDateForWeekDay year week 4
|
||||
in
|
||||
mondayDate ++ " bis " ++ fridayDate
|
||||
|
||||
|
||||
getYearWeekFromDate : String -> ( Int, Int )
|
||||
getYearWeekFromDate dateStr =
|
||||
let
|
||||
parts =
|
||||
String.split "-" dateStr
|
||||
|
||||
year =
|
||||
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
|
||||
|
||||
month =
|
||||
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
|
||||
day =
|
||||
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
||||
in
|
||||
( year, getISOWeek year month day )
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
module Utils.ErrorHandler exposing (handleApiError)
|
||||
|
||||
import Api.Decoders exposing (apiErrorDecoder)
|
||||
import Http
|
||||
import Json.Decode as Decode
|
||||
import Task
|
||||
import Types.Model exposing (ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
handleApiError : Http.Error -> Cmd Msg
|
||||
handleApiError error =
|
||||
let
|
||||
message =
|
||||
case error of
|
||||
Http.BadBody body ->
|
||||
case Decode.decodeString apiErrorDecoder body of
|
||||
Ok apiErr ->
|
||||
apiErr.message
|
||||
|
||||
Err _ ->
|
||||
"Ein Fehler ist aufgetreten"
|
||||
|
||||
Http.BadStatus 401 ->
|
||||
"Keine Berechtigung - bitte erneut anmelden"
|
||||
|
||||
Http.BadStatus 403 ->
|
||||
"Zugriff verweigert"
|
||||
|
||||
Http.BadStatus 404 ->
|
||||
"Ressource nicht gefunden"
|
||||
|
||||
Http.Timeout ->
|
||||
"Zeitüberschreitung - bitte erneut versuchen"
|
||||
|
||||
Http.NetworkError ->
|
||||
"Netzwerkfehler - bitte Verbindung prüfen"
|
||||
|
||||
_ ->
|
||||
"Ein unerwarteter Fehler ist aufgetreten"
|
||||
in
|
||||
Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ())
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
port module Utils.Ports exposing
|
||||
( confirmDelete
|
||||
, confirmDeleteResponse
|
||||
, removeToken
|
||||
, saveToken
|
||||
)
|
||||
|
||||
import Json.Encode as Encode
|
||||
|
||||
|
||||
port saveToken : Encode.Value -> Cmd msg
|
||||
|
||||
|
||||
port removeToken : () -> Cmd msg
|
||||
|
||||
|
||||
port confirmDelete : String -> Cmd msg
|
||||
|
||||
|
||||
port confirmDeleteResponse : (Bool -> msg) -> Sub msg
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
module Utils.TimeUtils exposing (calculateHours)
|
||||
|
||||
|
||||
calculateHours : String -> String -> Float
|
||||
calculateHours startTime endTime =
|
||||
let
|
||||
parseTime timeStr =
|
||||
case String.split ":" timeStr of
|
||||
[ h, m ] ->
|
||||
(String.toFloat h |> Maybe.withDefault 0)
|
||||
+ ((String.toFloat m |> Maybe.withDefault 0) / 60)
|
||||
|
||||
_ ->
|
||||
0
|
||||
|
||||
start =
|
||||
parseTime startTime
|
||||
|
||||
end =
|
||||
parseTime endTime
|
||||
in
|
||||
if end > start then
|
||||
end - start
|
||||
|
||||
else if endTime == "manual" then
|
||||
case String.toFloat startTime of
|
||||
Just time ->
|
||||
time
|
||||
|
||||
Nothing ->
|
||||
0
|
||||
|
||||
else
|
||||
0
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,99 +0,0 @@
|
|||
module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
|
||||
viewWeekNavigation : Model -> Html Msg
|
||||
viewWeekNavigation model =
|
||||
let
|
||||
dateRange =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.range
|
||||
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
div [ class "box" ]
|
||||
[ nav [ class "level" ]
|
||||
[ div [ class "level-left" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-primary"
|
||||
, onClick PreviousWeek
|
||||
]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-chevron-left" ] [] ]
|
||||
, span [] [ text "Vorherige Woche" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "level-item" ]
|
||||
[ div
|
||||
[ style "display" "flex"
|
||||
, style "flex-direction" "column"
|
||||
, style "align-items" "center"
|
||||
, style "gap" "0.5rem"
|
||||
, style "min-width" "250px"
|
||||
]
|
||||
[ p
|
||||
[ class "heading"
|
||||
, style "margin" "0"
|
||||
, style "line-height" "1.2"
|
||||
]
|
||||
[ text "Kalenderwoche" ]
|
||||
, p
|
||||
[ class "title is-3"
|
||||
, style "margin" "0"
|
||||
, style "line-height" "1.2"
|
||||
]
|
||||
[ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ]
|
||||
, p
|
||||
[ class "subtitle is-6"
|
||||
, style "margin" "0"
|
||||
, style "line-height" "1.2"
|
||||
]
|
||||
[ text dateRange ]
|
||||
]
|
||||
]
|
||||
, div [ class "level-right" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-primary"
|
||||
, onClick NextWeek
|
||||
]
|
||||
[ span [] [ text "Nächste Woche" ]
|
||||
, span [ class "icon" ]
|
||||
[ i [ class "fas fa-chevron-right" ] [] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg
|
||||
viewDayMobile model dayName ( dayOfWeek, schedules ) =
|
||||
let
|
||||
dateForDay =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.dates
|
||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|> Maybe.withDefault "N/A"
|
||||
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
div [ class "box mb-4" ]
|
||||
[ p [ class "has-text-weight-bold has-text-centered mb-3" ]
|
||||
[ text (dayName ++ " - " ++ dateForDay) ]
|
||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
||||
]
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
module View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg
|
||||
viewScheduleItemWithDay model dayOfWeek schedule =
|
||||
let
|
||||
isSelected =
|
||||
List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries
|
||||
|
||||
isClickable =
|
||||
(not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing
|
||||
|
||||
boxClass =
|
||||
if isSelected then
|
||||
"box has-background-success-light"
|
||||
|
||||
else if isClickable then
|
||||
"box has-background-white"
|
||||
|
||||
else
|
||||
"box has-background-light"
|
||||
|
||||
typeText =
|
||||
if schedule.scheduleType == "break" then
|
||||
" (Pause)"
|
||||
|
||||
else
|
||||
""
|
||||
|
||||
cursorStyle =
|
||||
if isClickable then
|
||||
"pointer"
|
||||
|
||||
else
|
||||
"not-allowed"
|
||||
|
||||
opacity =
|
||||
if isClickable || isSelected then
|
||||
"1"
|
||||
|
||||
else
|
||||
"0.6"
|
||||
in
|
||||
div
|
||||
[ class boxClass
|
||||
, onClick
|
||||
(if isClickable then
|
||||
ToggleScheduleSelection schedule.id dayOfWeek
|
||||
|
||||
else
|
||||
FetchSchedules
|
||||
)
|
||||
, style "cursor" cursorStyle
|
||||
, style "margin-bottom" "0.5rem"
|
||||
, style "padding" "0.75rem"
|
||||
, style "opacity" opacity
|
||||
, style "transition" "all 0.2s ease"
|
||||
, style "border"
|
||||
(if isClickable && not isSelected then
|
||||
"2px solid transparent"
|
||||
|
||||
else
|
||||
"2px solid currentColor"
|
||||
)
|
||||
]
|
||||
[ p [ class "has-text-weight-bold is-size-7" ]
|
||||
[ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
|
||||
, p [ class "is-size-7" ]
|
||||
[ text (schedule.title ++ typeText) ]
|
||||
]
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
module View.Components.Toast exposing (viewToasts)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule, Toast, ToastType(..))
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.TimeUtils exposing (calculateHours)
|
||||
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
|
||||
viewToasts : List Toast -> Html Msg
|
||||
viewToasts toasts =
|
||||
div [ class "toast-container" ]
|
||||
(List.map viewToast toasts)
|
||||
|
||||
|
||||
viewToast : Toast -> Html Msg
|
||||
viewToast toast =
|
||||
let
|
||||
toastClass =
|
||||
case toast.toastType of
|
||||
ErrorToast ->
|
||||
"toast-error"
|
||||
|
||||
SuccessToast ->
|
||||
"toast-success"
|
||||
|
||||
InfoToast ->
|
||||
"toast-info"
|
||||
|
||||
WarningToast ->
|
||||
"toast-warning"
|
||||
|
||||
icon =
|
||||
case toast.toastType of
|
||||
ErrorToast ->
|
||||
"fas fa-exclamation-circle"
|
||||
|
||||
SuccessToast ->
|
||||
"fas fa-check-circle"
|
||||
|
||||
InfoToast ->
|
||||
"fas fa-info-circle"
|
||||
|
||||
WarningToast ->
|
||||
"fas fa-exclamation-triangle"
|
||||
in
|
||||
div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ]
|
||||
[ div [ class "toast-content" ]
|
||||
[ span [ class "toast-icon" ]
|
||||
[ i [ class icon ] [] ]
|
||||
, span [ class "toast-message" ] [ text toast.message ]
|
||||
]
|
||||
, if toast.dismissible then
|
||||
button
|
||||
[ class "toast-close"
|
||||
, onClick (DismissToast toast.id)
|
||||
, attribute "aria-label" "Schließen"
|
||||
]
|
||||
[ i [ class "fas fa-times" ] [] ]
|
||||
|
||||
else
|
||||
text ""
|
||||
]
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
module View.Login exposing (viewLogin)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
|
||||
|
||||
viewLogin : Model -> Html Msg
|
||||
viewLogin model =
|
||||
section [ class "section" ]
|
||||
[ div [ class "container" ]
|
||||
[ div [ class "columns is-centered" ]
|
||||
[ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
|
||||
[ div [ class "box" ]
|
||||
[ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ]
|
||||
, div [ class "field" ]
|
||||
[ label [ class "label" ] [ text "Benutzername" ]
|
||||
, div [ class "control" ]
|
||||
[ input
|
||||
[ class "input"
|
||||
, type_ "text"
|
||||
, placeholder "Benutzername"
|
||||
, value model.username
|
||||
, onInput UpdateUsername
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "field" ]
|
||||
[ label [ class "label" ] [ text "Passwort" ]
|
||||
, div [ class "control" ]
|
||||
[ input
|
||||
[ class "input"
|
||||
, type_ "password"
|
||||
, placeholder "Passwort"
|
||||
, value model.password
|
||||
, onInput UpdatePassword
|
||||
]
|
||||
[]
|
||||
]
|
||||
]
|
||||
, div [ class "field" ]
|
||||
[ div [ class "control" ]
|
||||
[ button
|
||||
[ class "button is-primary is-fullwidth"
|
||||
, onClick Login
|
||||
]
|
||||
[ text "Anmelden" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
module View.UserDashboard exposing (viewUserDashboard)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Types.Model exposing (Model, Schedule)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Utils.TimeUtils exposing (calculateHours)
|
||||
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
||||
|
||||
|
||||
viewUserDashboard : Model -> Html Msg
|
||||
viewUserDashboard model =
|
||||
div []
|
||||
[ nav [ class "navbar is-primary" ]
|
||||
[ div [ class "navbar-brand" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
|
||||
]
|
||||
, a
|
||||
[ class
|
||||
("navbar-burger"
|
||||
++ (if model.mobileMenuOpen then
|
||||
" is-active"
|
||||
|
||||
else
|
||||
""
|
||||
)
|
||||
)
|
||||
, attribute "role" "navigation"
|
||||
, attribute "aria-label" "menu"
|
||||
, attribute "aria-expanded"
|
||||
(if model.mobileMenuOpen then
|
||||
"true"
|
||||
|
||||
else
|
||||
"false"
|
||||
)
|
||||
, onClick ToggleMobileMenu
|
||||
]
|
||||
[ span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
, span [ attribute "aria-hidden" "true" ] []
|
||||
]
|
||||
]
|
||||
, div
|
||||
[ id "navbarUser"
|
||||
, class
|
||||
("navbar-menu"
|
||||
++ (if model.mobileMenuOpen then
|
||||
" is-active"
|
||||
|
||||
else
|
||||
""
|
||||
)
|
||||
)
|
||||
]
|
||||
[ div [ class "navbar-end" ]
|
||||
[ div [ class "navbar-item" ]
|
||||
[ span [ class "has-text-white mr-2" ] [ text model.username ]
|
||||
]
|
||||
, div [ class "navbar-item" ]
|
||||
[ button [ class "button is-light", onClick Logout ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-sign-out-alt" ] [] ]
|
||||
, span [] [ text "Abmelden" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, section [ class "section" ]
|
||||
[ div [ class "container" ]
|
||||
[ viewWeekNavigation model
|
||||
, h2 [ class "title" ] [ text "Stundenplan" ]
|
||||
, if model.hasEntriesForCurrentWeek && not model.weekEditMode then
|
||||
div [ class "notification is-success" ]
|
||||
[ div [ class "level" ]
|
||||
[ div [ class "level-left" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-check-circle" ] [] ]
|
||||
, span [] [ text "Diese Woche wurde bereits erfasst" ]
|
||||
]
|
||||
]
|
||||
, div [ class "level-right" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-warning"
|
||||
, onClick EnableEditMode
|
||||
, disabled model.isProcessing
|
||||
]
|
||||
[ text "Bearbeiten" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
else if model.weekEditMode then
|
||||
div [ class "notification is-warning" ]
|
||||
[ div [ class "level" ]
|
||||
[ div [ class "level-left" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ span [ class "icon" ]
|
||||
[ i [ class "fas fa-edit" ] [] ]
|
||||
, span [] [ text "Bearbeitungsmodus aktiv" ]
|
||||
]
|
||||
]
|
||||
, div [ class "level-right" ]
|
||||
[ div [ class "level-item" ]
|
||||
[ button
|
||||
[ class "button is-danger is-small mr-2"
|
||||
, onClick DeleteWeekEntries
|
||||
, disabled model.isProcessing
|
||||
]
|
||||
[ text "Einträge löschen" ]
|
||||
, button
|
||||
[ class "button is-light is-small"
|
||||
, onClick DisableEditMode
|
||||
]
|
||||
[ text "Abbrechen" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
else
|
||||
div [ class "notification is-info is-light" ]
|
||||
[ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ]
|
||||
, viewScheduleGridWithWeek model
|
||||
, if not model.hasEntriesForCurrentWeek || model.weekEditMode then
|
||||
div [ class "field mt-4" ]
|
||||
[ div [ class "control" ]
|
||||
[ button
|
||||
[ class "button is-primary is-large is-fullwidth"
|
||||
, onClick SaveTimeEntries
|
||||
, disabled (List.isEmpty model.selectedEntries || model.isProcessing)
|
||||
]
|
||||
[ if model.isProcessing then
|
||||
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
|
||||
|
||||
else
|
||||
text ""
|
||||
, text
|
||||
(if model.weekEditMode then
|
||||
"Änderungen speichern"
|
||||
|
||||
else
|
||||
"Speichern"
|
||||
)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
else
|
||||
text ""
|
||||
, h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ]
|
||||
, viewUserYearlyTotal model
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
viewScheduleGridWithWeek : Model -> Html Msg
|
||||
viewScheduleGridWithWeek model =
|
||||
let
|
||||
days =
|
||||
[ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ]
|
||||
|
||||
groupedSchedules =
|
||||
List.range 0 4
|
||||
|> List.map
|
||||
(\day ->
|
||||
( day, List.filter (\s -> s.dayOfWeek == day) model.schedules )
|
||||
)
|
||||
in
|
||||
div []
|
||||
[ div [ class "is-hidden-mobile" ]
|
||||
[ div [ class "table-container" ]
|
||||
[ table [ class "table is-bordered is-fullwidth" ]
|
||||
[ thead []
|
||||
[ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days)
|
||||
]
|
||||
, tbody []
|
||||
[ tr []
|
||||
(List.map (viewDayColumnWithWeek model) groupedSchedules)
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, div [ class "is-hidden-tablet" ]
|
||||
(List.map2 (viewDayMobile model) days groupedSchedules)
|
||||
]
|
||||
|
||||
|
||||
viewUserYearlyTotal : Model -> Html Msg
|
||||
viewUserYearlyTotal model =
|
||||
let
|
||||
yearlyTotal =
|
||||
model.timeEntries
|
||||
|> List.map
|
||||
(\entry ->
|
||||
if entry.entryType == "lesson" then
|
||||
1.0
|
||||
|
||||
else
|
||||
Utils.TimeUtils.calculateHours entry.startTime entry.endTime
|
||||
)
|
||||
|> List.sum
|
||||
|
||||
userTarget =
|
||||
List.filter (\u -> not u.isAdmin) model.users
|
||||
|> List.head
|
||||
|> Maybe.map .yearlyWorkHours
|
||||
|> Maybe.withDefault 60
|
||||
|
||||
remaining =
|
||||
userTarget - yearlyTotal
|
||||
|
||||
progressPercent =
|
||||
Basics.min 100 (yearlyTotal / userTarget * 100)
|
||||
|
||||
progressColor =
|
||||
if remaining <= 0 then
|
||||
"is-success"
|
||||
|
||||
else if yearlyTotal >= userTarget * 0.8 then
|
||||
"is-info"
|
||||
|
||||
else
|
||||
"is-warning"
|
||||
in
|
||||
div [ class "box" ]
|
||||
[ div [ class "columns" ]
|
||||
[ div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Jahresenziel" ]
|
||||
, p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ]
|
||||
]
|
||||
, div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Geleistete Stunden" ]
|
||||
, p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ]
|
||||
]
|
||||
, div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Restliche Stunden" ]
|
||||
, p
|
||||
[ class
|
||||
("title is-4 "
|
||||
++ (if remaining <= 0 then
|
||||
"has-text-success"
|
||||
|
||||
else
|
||||
"has-text-warning"
|
||||
)
|
||||
)
|
||||
]
|
||||
[ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ]
|
||||
]
|
||||
]
|
||||
, progress
|
||||
[ class ("progress " ++ progressColor)
|
||||
, value (String.fromFloat progressPercent)
|
||||
, Html.Attributes.max "100"
|
||||
]
|
||||
[ text (String.fromFloat progressPercent ++ "%") ]
|
||||
]
|
||||
|
||||
|
||||
viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg
|
||||
viewDayColumnWithWeek model ( dayOfWeek, schedules ) =
|
||||
let
|
||||
dateForDay =
|
||||
case model.weekDates of
|
||||
Just wd ->
|
||||
wd.dates
|
||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
||||
|> List.head
|
||||
|> Maybe.map Tuple.second
|
||||
|> Maybe.withDefault "N/A"
|
||||
|
||||
Nothing ->
|
||||
"Laden..."
|
||||
in
|
||||
td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ]
|
||||
[ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ]
|
||||
[ text dateForDay ]
|
||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
||||
]
|
||||
|
||||
|
||||
viewUserWeeklySummary : Model -> Html Msg
|
||||
viewUserWeeklySummary model =
|
||||
case model.userWeeklySummary of
|
||||
Just summary ->
|
||||
let
|
||||
progressPercent =
|
||||
Basics.min 100 (summary.totalHours / summary.targetHours * 100)
|
||||
|
||||
progressColor =
|
||||
if summary.totalHours >= summary.targetHours then
|
||||
"is-success"
|
||||
|
||||
else if summary.totalHours >= summary.targetHours * 0.8 then
|
||||
"is-info"
|
||||
|
||||
else
|
||||
"is-warning"
|
||||
in
|
||||
div [ class "box" ]
|
||||
[ div [ class "columns" ]
|
||||
[ div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ]
|
||||
, p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ]
|
||||
, p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ]
|
||||
]
|
||||
, div [ class "column" ]
|
||||
[ p [ class "heading" ] [ text "Verbleibend" ]
|
||||
, p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ]
|
||||
[ text (String.fromFloat summary.remainingHours ++ " Std.") ]
|
||||
, if summary.remainingHours < 0 then
|
||||
p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ]
|
||||
|
||||
else
|
||||
p [ class "subtitle is-6" ] [ text "" ]
|
||||
]
|
||||
]
|
||||
, progress
|
||||
[ class ("progress " ++ progressColor)
|
||||
, value (String.fromFloat progressPercent)
|
||||
, Html.Attributes.max "100"
|
||||
]
|
||||
[ text (String.fromFloat progressPercent ++ "%") ]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
div [ class "box" ]
|
||||
[ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ]
|
||||
]
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
module View.View exposing (view)
|
||||
|
||||
import Html exposing (Html, div)
|
||||
import Html.Attributes exposing (class)
|
||||
import Types.Model exposing (Model)
|
||||
import Types.Msg exposing (Msg(..))
|
||||
import Types.Page exposing (Page(..))
|
||||
import View.AdminDashboard exposing (viewAdminDashboard)
|
||||
import View.Components.Toast exposing (viewToasts)
|
||||
import View.Login exposing (viewLogin)
|
||||
import View.UserDashboard exposing (viewUserDashboard)
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "app-container" ]
|
||||
[ viewToasts model.toasts
|
||||
, div [ class "container" ]
|
||||
[ case model.page of
|
||||
LoginPage ->
|
||||
viewLogin model
|
||||
|
||||
UserDashboard ->
|
||||
viewUserDashboard model
|
||||
|
||||
AdminDashboard ->
|
||||
viewAdminDashboard model
|
||||
]
|
||||
]
|
||||
2
frontend/src/app.css
Normal file
2
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
1
frontend/src/assets/svelte.svg
Normal file
1
frontend/src/assets/svelte.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
388
frontend/src/components/AdminDashboard.svelte
Normal file
388
frontend/src/components/AdminDashboard.svelte
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<script>
|
||||
import { auth } from "../lib/stores";
|
||||
import { logout } from "../lib/api";
|
||||
|
||||
import AdminScheduleTab from "./admin/AdminScheduleTab.svelte";
|
||||
import AdminUsersTab from "./admin/AdminUsersTab.svelte";
|
||||
import AdminTimeEntriesTab from "./admin/AdminTimeEntriesTab.svelte";
|
||||
import AdminSchoolYearsTab from "./admin/AdminSchoolYearsTab.svelte";
|
||||
import AdminSettingsTab from "./admin/AdminSettingsTab.svelte";
|
||||
import AdminSubstitutionsTab from "./admin/AdminSubstitutionsTab.svelte";
|
||||
|
||||
let activeTab = "schedule";
|
||||
const user = $auth.user;
|
||||
|
||||
$: pageTitle = getPageTitle(activeTab);
|
||||
|
||||
function getPageTitle(tab) {
|
||||
switch (tab) {
|
||||
case "schedule":
|
||||
return "Stundenplan Konfiguration";
|
||||
case "users":
|
||||
return "Benutzerverwaltung";
|
||||
case "timeEntries":
|
||||
return "Zeiteinträge & Buchungen";
|
||||
case "schoolYears":
|
||||
return "Schuljahre & Perioden";
|
||||
case "settings":
|
||||
return "Einstellungen";
|
||||
case "substitutions":
|
||||
return "Vertretungen";
|
||||
default:
|
||||
return "Admin";
|
||||
}
|
||||
}
|
||||
|
||||
let isDrawerOpen = false;
|
||||
function closeDrawer() {
|
||||
isDrawerOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input
|
||||
id="admin-drawer"
|
||||
type="checkbox"
|
||||
class="drawer-toggle"
|
||||
bind:checked={isDrawerOpen}
|
||||
/>
|
||||
|
||||
<div class="drawer-content flex flex-col bg-base-200 min-h-screen">
|
||||
<div
|
||||
class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8"
|
||||
>
|
||||
<div class="flex-none lg:hidden">
|
||||
<label for="admin-drawer" class="btn btn-square btn-ghost">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block w-6 h-6 stroke-current"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path></svg
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="text-sm breadcrumbs hidden sm:block">
|
||||
<ul>
|
||||
<li class="opacity-50">Admin</li>
|
||||
<li class="font-bold text-primary">{pageTitle}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="font-bold text-lg sm:hidden">{pageTitle}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-none flex items-center gap-4">
|
||||
<div class="hidden sm:block text-right leading-tight">
|
||||
<div class="font-bold text-sm">{$auth.user?.username}</div>
|
||||
<div class="text-xs opacity-50">Administrator</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder"
|
||||
>
|
||||
<div
|
||||
class="bg-neutral text-neutral-content rounded-full w-10"
|
||||
>
|
||||
<span class="text-xl"
|
||||
>{user?.username?.charAt(0).toUpperCase()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
on:click={logout}
|
||||
class="text-error font-bold">Abmelden</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 md:p-8 lg:p-10 fade-in">
|
||||
{#if activeTab === "schedule"}
|
||||
<AdminScheduleTab />
|
||||
{:else if activeTab === "users"}
|
||||
<AdminUsersTab />
|
||||
{:else if activeTab === "timeEntries"}
|
||||
<AdminTimeEntriesTab />
|
||||
{:else if activeTab === "schoolYears"}
|
||||
<AdminSchoolYearsTab />
|
||||
{:else if activeTab === "settings"}
|
||||
<AdminSettingsTab />
|
||||
{:else if activeTab === "substitutions"}
|
||||
<AdminSubstitutionsTab />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-40">
|
||||
<label for="admin-drawer" class="drawer-overlay"></label>
|
||||
|
||||
<aside
|
||||
class="bg-base-100 w-80 h-full flex flex-col border-r border-base-300"
|
||||
>
|
||||
<div class="p-6 border-b border-base-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<img
|
||||
src="/api/logo?t={Date.now()}"
|
||||
alt="Logo"
|
||||
class="w-full h-full object-contain"
|
||||
on:error={(e) => {
|
||||
e.target.style.display = "none";
|
||||
e.target.nextElementSibling.style.display =
|
||||
"flex";
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="hidden w-10 h-10 rounded bg-primary text-primary-content font-bold text-xl items-center justify-center"
|
||||
>
|
||||
Z
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-bold text-xl tracking-tight">
|
||||
Zeiterfassung
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs font-mono opacity-50 mt-1 pl-14">
|
||||
Admin Dashboard
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="menu p-4 w-full gap-2 text-base font-medium flex-1">
|
||||
<li
|
||||
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
|
||||
>
|
||||
Verwaltung
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
class={activeTab === "schedule"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeTab = "schedule";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/></svg
|
||||
>
|
||||
Stundenplan
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={activeTab === "users"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeTab = "users";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/></svg
|
||||
>
|
||||
Benutzer
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li
|
||||
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1"
|
||||
>
|
||||
Daten
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
class={activeTab === "timeEntries"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeTab = "timeEntries";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
Zeiteinträge
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={activeTab === "schoolYears"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeTab = "schoolYears";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/></svg
|
||||
>
|
||||
Schuljahre
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={activeTab === "substitutions"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeTab = "substitutions";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/></svg
|
||||
>
|
||||
Vertretungen
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1"
|
||||
>
|
||||
System
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={activeTab === "settings"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeTab = "settings";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/></svg
|
||||
>
|
||||
Einstellungen
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="p-4 border-t border-base-200">
|
||||
<button
|
||||
on:click={logout}
|
||||
class="btn btn-ghost btn-sm w-full justify-start text-error"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 mr-2"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/></svg
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Sanfte Fade-In Animation für Tab-Wechsel */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
frontend/src/components/Login.svelte
Normal file
130
frontend/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<script>
|
||||
import { login } from "../lib/api";
|
||||
import { loading } from "../lib/stores";
|
||||
|
||||
let username = "";
|
||||
let password = "";
|
||||
let showPassword = false;
|
||||
|
||||
let logoSrc = "/api/logo?t=" + Date.now();
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username || !password) return;
|
||||
await login(username, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="hero min-h-screen bg-base-200">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||
<div class="text-center lg:text-left ml-0 lg:ml-8 mb-4 lg:mb-0">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="Schul-Logo"
|
||||
class="w-32 h-32 mb-6 mx-auto lg:mx-0 object-contain"
|
||||
on:error={(e) => (e.target.style.display = "none")}
|
||||
/>
|
||||
<h1 class="text-5xl font-bold text-primary">Zeiterfassung</h1>
|
||||
<p class="py-6 max-w-md">
|
||||
Willkommen zurück. Bitte melden Sie sich an, um Ihre Arbeitszeiten zu
|
||||
erfassen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label" for="username">
|
||||
<span class="label-text">Benutzername</span>
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Benutzername"
|
||||
class="input input-bordered"
|
||||
bind:value={username}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="password">
|
||||
<span class="label-text">Passwort</span>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
class="input input-bordered w-full pr-10"
|
||||
bind:value={password}
|
||||
on:keydown={(e) => e.key === "Enter" && handleLogin()}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-base-content/60 hover:text-primary z-10"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if showPassword}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="label">
|
||||
<a href="#" class="label-text-alt link link-hover"
|
||||
>Passwort vergessen?</a
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={handleLogin}
|
||||
disabled={$loading}
|
||||
>
|
||||
{#if $loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{/if}
|
||||
Anmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
49
frontend/src/components/ScheduleItem.svelte
Normal file
49
frontend/src/components/ScheduleItem.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let schedule;
|
||||
export let dayOfWeek;
|
||||
export let isSelected = false;
|
||||
export let isClickable = true;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: bgClass = isSelected
|
||||
? "bg-success text-success-content shadow-md scale-[1.02]"
|
||||
: isClickable
|
||||
? "bg-base-200 hover:bg-base-300 hover:shadow-sm"
|
||||
: "bg-base-100 opacity-40 grayscale";
|
||||
|
||||
$: cursorClass = isClickable ? "cursor-pointer" : "cursor-default";
|
||||
|
||||
$: borderClass = isSelected
|
||||
? "border-l-4 border-l-success-content/20"
|
||||
: isClickable
|
||||
? "border-l-4 border-l-transparent hover:border-l-primary"
|
||||
: "border-l-4 border-l-transparent";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="card rounded-lg transition-all duration-200 {bgClass} {cursorClass} {borderClass}"
|
||||
on:click={() => isClickable && dispatch("toggle")}
|
||||
on:keydown={() => {}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="p-3 text-center">
|
||||
<div class="font-mono font-bold text-sm opacity-90">
|
||||
{schedule.startTime} - {schedule.endTime}
|
||||
</div>
|
||||
<div class="text-sm font-medium mt-1 truncate">
|
||||
{schedule.title}
|
||||
</div>
|
||||
{#if schedule.scheduleType === "break"}
|
||||
<div class="mt-1">
|
||||
<span
|
||||
class="badge badge-xs badge-ghost uppercase tracking-tighter text-[10px]"
|
||||
>Pause</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
42
frontend/src/components/ScheduleItems.svelte
Normal file
42
frontend/src/components/ScheduleItems.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let schedule;
|
||||
export let dayOfWeek;
|
||||
export let isSelected = false;
|
||||
export let isClickable = true;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: boxClass = isSelected
|
||||
? "box has-background-success-light"
|
||||
: isClickable
|
||||
? "box has-background-white"
|
||||
: "box has-background-light";
|
||||
|
||||
$: cursorStyle = isClickable ? "pointer" : "not-allowed";
|
||||
|
||||
$: opacity = isClickable || isSelected ? "1" : "0.6";
|
||||
|
||||
$: borderStyle =
|
||||
isClickable && !isSelected
|
||||
? "2px solid transparent"
|
||||
: "2px solid currentColor";
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={boxClass}
|
||||
style="cursor: {cursorStyle}; margin-bottom: 0.5rem; padding: 0.75rem; opacity: {opacity}; transition: all 0.2s ease; border: {borderStyle}"
|
||||
on:click={() => isClickable && dispatch("toggle")}
|
||||
on:keydown={() => {}}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<p class="has-text-weight-bold is-size-7">
|
||||
{schedule.startTime} - {schedule.endTime}
|
||||
</p>
|
||||
<p class="is-size-7">
|
||||
{schedule.title}
|
||||
{schedule.scheduleType === "break" ? "(Pause)" : ""}
|
||||
</p>
|
||||
</div>
|
||||
74
frontend/src/components/ToastNotification.svelte
Normal file
74
frontend/src/components/ToastNotification.svelte
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<script>
|
||||
import { toasts, removeToast } from "../lib/stores";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
function getAlertClass(type) {
|
||||
switch (type) {
|
||||
case "error":
|
||||
return "alert-error";
|
||||
case "warning":
|
||||
return "alert-warning";
|
||||
case "success":
|
||||
return "alert-success";
|
||||
default:
|
||||
return "alert-info";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toast toast-top toast-end z-50">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="alert {getAlertClass(toast.type)} shadow-lg min-w-[300px]"
|
||||
transition:fly={{ y: -20, duration: 300 }}
|
||||
>
|
||||
{#if toast.type === "error"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
{:else if toast.type === "success"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path></svg
|
||||
>
|
||||
{/if}
|
||||
|
||||
<span>{toast.message}</span>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
on:click={() => removeToast(toast.id)}>✕</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
899
frontend/src/components/UserDashboard.svelte
Normal file
899
frontend/src/components/UserDashboard.svelte
Normal file
|
|
@ -0,0 +1,899 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { auth, addToast } from "../lib/stores";
|
||||
import {
|
||||
logout,
|
||||
getSchedules,
|
||||
getMyTimeEntries,
|
||||
saveTimeEntriesBatch,
|
||||
deleteWeekEntries,
|
||||
changeMyPassword,
|
||||
getMyInfo,
|
||||
getOpenSubstitutions,
|
||||
acceptSubstitution,
|
||||
} from "../lib/api";
|
||||
import {
|
||||
getISOWeek,
|
||||
getISOYear,
|
||||
formatDate,
|
||||
getDateOfISOWeek,
|
||||
calculateHours,
|
||||
} from "../lib/utils";
|
||||
import ScheduleItem from "./ScheduleItem.svelte";
|
||||
|
||||
const today = new Date();
|
||||
let currentISOYear = getISOYear(today);
|
||||
let currentWeek = getISOWeek(today);
|
||||
|
||||
let schedules = [];
|
||||
let allEntries = [];
|
||||
let existingEntries = [];
|
||||
let selectedEntries = [];
|
||||
let weekEditMode = false;
|
||||
|
||||
let processing = false;
|
||||
let isLoadingData = true;
|
||||
let isDrawerOpen = false;
|
||||
|
||||
let showPwModal = false;
|
||||
let pwData = { old: "", new1: "", new2: "" };
|
||||
|
||||
let activeView = "schedule";
|
||||
let openSubstitutions = [];
|
||||
|
||||
$: hasEntriesForWeek = existingEntries.some((e) => e.entryType !== "manual");
|
||||
|
||||
$: weekDates = Array.from({ length: 5 }, (_, i) => {
|
||||
const d = getDateOfISOWeek(currentWeek, currentISOYear);
|
||||
d.setDate(d.getDate() + i);
|
||||
return {
|
||||
name: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"][i],
|
||||
date: formatDate(d),
|
||||
dayIndex: i,
|
||||
};
|
||||
});
|
||||
|
||||
$: yearlyTotal = allEntries.reduce((sum, entry) => {
|
||||
let hours = 0;
|
||||
if (entry.entryType === "lesson") hours = 1.0;
|
||||
else hours = calculateHours(entry.startTime, entry.endTime);
|
||||
return sum + hours;
|
||||
}, 0);
|
||||
|
||||
$: userTarget = $auth.user?.yearlyWorkHours || 60;
|
||||
$: remaining = userTarget - yearlyTotal;
|
||||
$: progressPercent =
|
||||
userTarget > 0 ? Math.min(100, (yearlyTotal / userTarget) * 100) : 0;
|
||||
$: progressClass =
|
||||
remaining <= 0
|
||||
? "progress-success"
|
||||
: yearlyTotal >= userTarget * 0.8
|
||||
? "progress-info"
|
||||
: "progress-warning";
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
async function handleDeleteWeek() {
|
||||
if (!confirm("Möchten Sie wirklich alle Einträge dieser Woche löschen?"))
|
||||
return;
|
||||
|
||||
processing = true;
|
||||
try {
|
||||
await deleteWeekEntries(currentISOYear, currentWeek);
|
||||
addToast("Woche erfolgreich zurückgesetzt", "success");
|
||||
weekEditMode = false;
|
||||
selectedEntries = [];
|
||||
existingEntries = [];
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoadingData = true;
|
||||
try {
|
||||
const [schedulesData, entriesData, userData, subsData] =
|
||||
await Promise.all([
|
||||
getSchedules(),
|
||||
getMyTimeEntries(),
|
||||
getMyInfo(),
|
||||
getOpenSubstitutions(),
|
||||
]);
|
||||
schedules = schedulesData;
|
||||
allEntries = entriesData;
|
||||
openSubstitutions = subsData;
|
||||
|
||||
auth.update((current) => ({
|
||||
...current,
|
||||
user: userData,
|
||||
}));
|
||||
|
||||
filterEntries(entriesData);
|
||||
} finally {
|
||||
isLoadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAcceptSub(sub) {
|
||||
if (
|
||||
!confirm(
|
||||
`Möchten Sie die Vertretung "${sub.title}" am ${sub.date} übernehmen?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await acceptSubstitution(sub.id);
|
||||
addToast("Erfolgreich übernommen! Stunden wurden gebucht.", "success");
|
||||
await loadData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToWeekOfDate(dateStr) {
|
||||
const [y, m, d] = dateStr.split("-").map(Number);
|
||||
const targetDate = new Date(y, m - 1, d);
|
||||
|
||||
const targetWeek = getISOWeek(targetDate);
|
||||
const targetYear = getISOYear(targetDate);
|
||||
|
||||
currentISOYear = targetYear;
|
||||
currentWeek = targetWeek;
|
||||
|
||||
activeView = "schedule";
|
||||
loadData();
|
||||
}
|
||||
|
||||
function filterEntries(entries) {
|
||||
existingEntries = entries.filter((e) => {
|
||||
const [y, m, d] = e.date.split("-").map(Number);
|
||||
const entryDate = new Date(y, m - 1, d);
|
||||
return (
|
||||
getISOYear(entryDate) === currentISOYear &&
|
||||
getISOWeek(entryDate) === currentWeek
|
||||
);
|
||||
});
|
||||
selectedEntries = existingEntries
|
||||
.map((e) => {
|
||||
const sched = schedules.find((s) => s.id === e.scheduleId);
|
||||
if (sched)
|
||||
return { scheduleId: e.scheduleId, dayOfWeek: sched.dayOfWeek };
|
||||
return null;
|
||||
})
|
||||
.filter((item) => item !== null);
|
||||
}
|
||||
|
||||
function toggleSelection(scheduleId, dayOfWeek) {
|
||||
const index = selectedEntries.findIndex(
|
||||
(e) => e.scheduleId === scheduleId && e.dayOfWeek === dayOfWeek,
|
||||
);
|
||||
if (index >= 0) selectedEntries.splice(index, 1);
|
||||
else selectedEntries.push({ scheduleId, dayOfWeek });
|
||||
selectedEntries = selectedEntries;
|
||||
}
|
||||
|
||||
async function saveEntries() {
|
||||
processing = true;
|
||||
try {
|
||||
const entriesToSave = selectedEntries.map((sel) => {
|
||||
const sched = schedules.find((s) => s.id === sel.scheduleId);
|
||||
const dateObj = weekDates.find((d) => d.dayIndex === sel.dayOfWeek);
|
||||
return {
|
||||
schedule_id: sel.scheduleId,
|
||||
date: dateObj.date,
|
||||
type: sched.scheduleType,
|
||||
start_time: sched.startTime,
|
||||
end_time: sched.endTime,
|
||||
};
|
||||
});
|
||||
if (entriesToSave.length > 0) await saveTimeEntriesBatch(entriesToSave);
|
||||
weekEditMode = false;
|
||||
await loadData();
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeWeek(delta) {
|
||||
const d = getDateOfISOWeek(currentWeek, currentISOYear);
|
||||
d.setDate(d.getDate() + delta * 7);
|
||||
currentWeek = getISOWeek(d);
|
||||
currentISOYear = getISOYear(d);
|
||||
loadData();
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
isDrawerOpen = false;
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
if (pwData.new1 !== pwData.new2) {
|
||||
addToast("Die neuen Passwörter stimmen nicht überein", "warning");
|
||||
return;
|
||||
}
|
||||
if (pwData.new1.length < 6) {
|
||||
addToast("Passwort zu kurz (min. 6 Zeichen)", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await changeMyPassword(pwData.old, pwData.new1);
|
||||
addToast("Passwort erfolgreich geändert!", "success");
|
||||
showPwModal = false;
|
||||
pwData = { old: "", new1: "", new2: "" };
|
||||
} catch (e) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input
|
||||
id="user-drawer"
|
||||
type="checkbox"
|
||||
class="drawer-toggle"
|
||||
bind:checked={isDrawerOpen}
|
||||
/>
|
||||
|
||||
<div class="drawer-content flex flex-col bg-base-200 min-h-screen pb-20">
|
||||
<div
|
||||
class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8"
|
||||
>
|
||||
<div class="flex-none lg:hidden">
|
||||
<label for="user-drawer" class="btn btn-square btn-ghost">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block w-6 h-6 stroke-current"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path></svg
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="text-sm breadcrumbs hidden sm:block">
|
||||
<ul>
|
||||
<li class="opacity-50">Mein Bereich</li>
|
||||
<li class="font-bold text-primary">
|
||||
{activeView === "schedule" ? "Stundenplan" : "Vertretungsbörse"}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="font-bold text-lg sm:hidden">
|
||||
{activeView === "schedule" ? `KW ${currentWeek}` : "Vertretungen"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-none flex items-center gap-4">
|
||||
<div class="hidden sm:block text-right leading-tight">
|
||||
<div class="font-bold text-sm">{$auth.user?.username}</div>
|
||||
<div class="text-xs opacity-50">Benutzer</div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder"
|
||||
>
|
||||
<div class="bg-primary text-primary-content rounded-full w-10">
|
||||
<span class="text-xl"
|
||||
>{$auth.user?.username?.charAt(0).toUpperCase()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li>
|
||||
<button on:click={logout} class="text-error font-bold"
|
||||
>Abmelden</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 md:p-8 lg:p-10 fade-in space-y-6">
|
||||
{#if activeView === "schedule"}
|
||||
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||
<div
|
||||
class="card-body p-2 sm:p-4 flex-row items-center justify-between"
|
||||
>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost"
|
||||
on:click={() => changeWeek(-1)}
|
||||
disabled={isLoadingData}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1"
|
||||
>
|
||||
Kalenderwoche
|
||||
</p>
|
||||
<h2 class="text-xl sm:text-3xl font-bold">
|
||||
KW {currentWeek} <span class="text-base-content/30">/</span>
|
||||
{currentISOYear}
|
||||
</h2>
|
||||
<p
|
||||
class="text-xs sm:text-sm opacity-60 mt-1 font-mono bg-base-200 inline-block px-2 py-1 rounded"
|
||||
>
|
||||
{weekDates[0]?.date} — {weekDates[4]?.date}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost"
|
||||
on:click={() => changeWeek(1)}
|
||||
disabled={isLoadingData}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden grid grid-cols-2 gap-2">
|
||||
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
|
||||
<div class="stat p-2 text-center">
|
||||
<div class="stat-desc">Geleistet</div>
|
||||
<div class="stat-value text-lg">{yearlyTotal.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
|
||||
<div class="stat p-2 text-center">
|
||||
<div class="stat-desc">Offen</div>
|
||||
<div
|
||||
class="stat-value text-lg {remaining <= 0
|
||||
? 'text-success'
|
||||
: 'text-warning'}"
|
||||
>
|
||||
{Math.max(0, remaining).toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoadingData}
|
||||
<div
|
||||
class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="flex-1 space-y-4">
|
||||
<div class="skeleton h-8 w-full mb-4"></div>
|
||||
<div class="skeleton h-20 w-full rounded"></div>
|
||||
<div class="skeleton h-20 w-full rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:hidden space-y-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="skeleton h-16 w-full rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasEntriesForWeek && !weekEditMode}
|
||||
<div role="alert" class="alert alert-success shadow-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/></svg
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-bold">Erfasst!</h3>
|
||||
<div class="text-xs">Stunden gespeichert.</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-outline"
|
||||
on:click={() => (weekEditMode = true)}
|
||||
disabled={processing}>Bearbeiten</button
|
||||
>
|
||||
</div>
|
||||
{:else if weekEditMode}
|
||||
<div role="alert" class="alert alert-warning shadow-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/></svg
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-bold">Bearbeitungsmodus</h3>
|
||||
<div class="text-xs">Speichern nicht vergessen!</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-error"
|
||||
on:click={handleDeleteWeek}
|
||||
disabled={processing}>Löschen</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
on:click={() => (weekEditMode = false)}>Abbrechen</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div role="alert" class="alert alert-info shadow-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path></svg
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-bold">Zeiterfassung</h3>
|
||||
<div class="text-xs">Wählen Sie Ihre Stunden.</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="overflow-x-auto hidden lg:block bg-base-100 rounded-2xl shadow-xl border border-base-200"
|
||||
>
|
||||
<table class="table table-fixed w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each weekDates as day}
|
||||
<th class="text-center bg-base-200/50 py-4">
|
||||
<div class="font-bold text-lg">{day.name}</div>
|
||||
<div class="text-xs font-normal opacity-50">
|
||||
{day.date}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{#each weekDates as day}
|
||||
<td
|
||||
class="align-top p-2 min-w-[160px] border-r border-base-200 last:border-0"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
|
||||
<ScheduleItem
|
||||
{schedule}
|
||||
dayOfWeek={day.dayIndex}
|
||||
isSelected={selectedEntries.some(
|
||||
(e) =>
|
||||
e.scheduleId === schedule.id &&
|
||||
e.dayOfWeek === day.dayIndex,
|
||||
)}
|
||||
isClickable={(!hasEntriesForWeek || weekEditMode) &&
|
||||
!processing}
|
||||
on:toggle={() =>
|
||||
toggleSelection(schedule.id, day.dayIndex)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden space-y-4">
|
||||
{#each weekDates as day}
|
||||
<div
|
||||
class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200"
|
||||
>
|
||||
<input type="checkbox" />
|
||||
<div
|
||||
class="collapse-title text-lg font-medium flex justify-between items-center"
|
||||
>
|
||||
<span>{day.name}</span>
|
||||
<span class="text-sm opacity-50 font-mono">{day.date}</span>
|
||||
</div>
|
||||
<div class="collapse-content">
|
||||
<div class="pt-2 space-y-2">
|
||||
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
|
||||
<ScheduleItem
|
||||
{schedule}
|
||||
dayOfWeek={day.dayIndex}
|
||||
isSelected={selectedEntries.some(
|
||||
(e) =>
|
||||
e.scheduleId === schedule.id &&
|
||||
e.dayOfWeek === day.dayIndex,
|
||||
)}
|
||||
isClickable={(!hasEntriesForWeek || weekEditMode) &&
|
||||
!processing}
|
||||
on:toggle={() =>
|
||||
toggleSelection(schedule.id, day.dayIndex)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeView === "market"}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Offene Vertretungen</h2>
|
||||
<button class="btn btn-ghost btn-sm" on:click={loadData}
|
||||
><i class="fas fa-sync"></i> Refresh</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if openSubstitutions.length === 0}
|
||||
<div class="hero bg-base-100 rounded-xl border border-base-200 py-12">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-xl font-bold opacity-50">Alles ruhig</h1>
|
||||
<p class="py-6 opacity-70">
|
||||
Aktuell werden keine Vertretungen gesucht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{#each openSubstitutions as sub}
|
||||
<div
|
||||
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-start">
|
||||
<h3 class="card-title text-primary">{sub.title}</h3>
|
||||
<div class="badge badge-outline font-mono">{sub.date}</div>
|
||||
</div>
|
||||
|
||||
<p class="text-2xl font-bold my-2">
|
||||
{sub.start_time}
|
||||
<span class="text-base font-normal opacity-50"
|
||||
>- {sub.end_time}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
{#if sub.notes}
|
||||
<div
|
||||
class="alert alert-ghost text-sm py-2 px-3 my-2 bg-base-200/50"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info shrink-0 w-4 h-4"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path></svg
|
||||
>
|
||||
<span>{sub.notes}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-actions justify-end mt-4 items-center">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-xs"
|
||||
on:click={() => jumpToWeekOfDate(sub.date)}
|
||||
>
|
||||
Im Plan prüfen
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
on:click={() => handleAcceptSub(sub)}
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if activeView === "schedule" && (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
|
||||
<div
|
||||
class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none"
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary btn-lg w-full shadow-2xl border-primary-focus transform active:scale-[0.99] transition-transform pointer-events-auto"
|
||||
disabled={selectedEntries.length === 0 || processing}
|
||||
on:click={saveEntries}
|
||||
>
|
||||
{#if processing}<span class="loading loading-spinner"></span>{/if}
|
||||
{weekEditMode ? "Änderungen speichern" : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-40">
|
||||
<label for="user-drawer" class="drawer-overlay"></label>
|
||||
|
||||
<aside
|
||||
class="bg-base-100 w-80 h-full flex flex-col border-r border-base-300"
|
||||
>
|
||||
<div class="p-6 border-b border-base-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<img
|
||||
src="/api/logo?t={Date.now()}"
|
||||
alt="Logo"
|
||||
class="w-full h-full object-contain"
|
||||
on:error={(e) => {
|
||||
e.target.style.display = "none";
|
||||
e.target.nextElementSibling.style.display = "flex";
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="hidden w-10 h-10 rounded bg-primary text-primary-content font-bold text-xl items-center justify-center"
|
||||
>
|
||||
Z
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-bold text-xl tracking-tight">Zeiterfassung</div>
|
||||
</div>
|
||||
<div class="text-xs font-mono opacity-50 mt-1 pl-14">
|
||||
User Dashboard
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="menu p-4 w-full gap-2 text-base font-medium">
|
||||
<li
|
||||
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
|
||||
>
|
||||
Navigation
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={activeView === "schedule"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeView = "schedule";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/></svg
|
||||
>
|
||||
Stundenplan
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class={activeView === "market"
|
||||
? "active bg-primary/10 text-primary"
|
||||
: ""}
|
||||
on:click={() => {
|
||||
activeView = "market";
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.05 4.575a1.575 1.575 0 10-3.15 0v3m3.15-3v-1.5a1.575 1.575 0 013.15 0v1.5m-3.15 0l.075 5.925m3.075.75V4.575m0 0a1.575 1.575 0 013.15 0V15M6.9 7.575V12.75M5.25 21h13.5c1.125 0 2.025-.9 2.025-2.025v-9.75c0-1.125-.9-2.025-2.025-2.025H5.25A2.25 2.25 0 003 9.375v9.75c0 1.125.9 2.025 2.025 2.025z"
|
||||
/></svg
|
||||
>
|
||||
Vertretungsbörse
|
||||
{#if openSubstitutions.length > 0}
|
||||
<span class="badge badge-sm badge-secondary ml-auto"
|
||||
>{openSubstitutions.length}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => (showPwModal = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/></svg
|
||||
>
|
||||
Passwort ändern
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-auto p-4 bg-base-200 m-4 rounded-xl space-y-4">
|
||||
<div class="text-xs font-bold uppercase opacity-50 tracking-wider">
|
||||
Jahresfortschritt
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-end">
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
{yearlyTotal.toFixed(1)}
|
||||
</div>
|
||||
<div class="text-xs opacity-70">
|
||||
von {userTarget.toFixed(1)} Stunden
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="radial-progress text-primary text-xs font-bold"
|
||||
style="--value:{progressPercent}; --size:3rem;"
|
||||
>
|
||||
{Math.round(progressPercent)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-1"></div>
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="opacity-70">Verbleibend:</span>
|
||||
<span
|
||||
class="font-bold {remaining <= 0 ? 'text-success' : 'text-warning'}"
|
||||
>
|
||||
{Math.max(0, remaining).toFixed(1)} h
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
class="progress w-full h-2 {progressClass}"
|
||||
value={progressPercent}
|
||||
max="100"
|
||||
></progress>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-base-200">
|
||||
<button
|
||||
on:click={logout}
|
||||
class="btn btn-ghost btn-sm w-full justify-start text-error"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 mr-2"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/></svg
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog class="modal {showPwModal ? 'modal-open' : ''}">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Passwort ändern</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">Altes Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input input-bordered"
|
||||
bind:value={pwData.old}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input input-bordered"
|
||||
bind:value={pwData.new1}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">Wiederholung</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input input-bordered"
|
||||
bind:value={pwData.new2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" on:click={() => (showPwModal = false)}
|
||||
>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={handleChangePassword}
|
||||
disabled={!pwData.old || !pwData.new1}>Speichern</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
420
frontend/src/components/admin/AdminScheduleTab.svelte
Normal file
420
frontend/src/components/admin/AdminScheduleTab.svelte
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getSchedules,
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
} from "../../lib/api";
|
||||
import { loading, addToast } from "../../lib/stores";
|
||||
|
||||
let schedules = [];
|
||||
let fileInput;
|
||||
|
||||
let newSchedule = {
|
||||
dayOfWeek: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
scheduleType: "lesson",
|
||||
title: "",
|
||||
};
|
||||
|
||||
const dayNames = [
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
];
|
||||
|
||||
let showCopyModal = false;
|
||||
let copySourceDay = "";
|
||||
let copyTargetDays = [];
|
||||
let deleteExisting = true;
|
||||
let copyProcessing = false;
|
||||
let importProcessing = false;
|
||||
|
||||
onMount(loadSchedules);
|
||||
|
||||
async function loadSchedules() {
|
||||
schedules = await getSchedules();
|
||||
}
|
||||
|
||||
function isValidTimeRange(start, end) {
|
||||
if (!start || !end) return false;
|
||||
const [h1, m1] = start.split(":").map(Number);
|
||||
const [h2, m2] = end.split(":").map(Number);
|
||||
return h2 * 60 + m2 > h1 * 60 + m1;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (
|
||||
newSchedule.dayOfWeek === "" ||
|
||||
!newSchedule.startTime ||
|
||||
!newSchedule.endTime
|
||||
) {
|
||||
addToast("Bitte alle Felder ausfüllen", "warning");
|
||||
return;
|
||||
}
|
||||
if (!isValidTimeRange(newSchedule.startTime, newSchedule.endTime)) {
|
||||
addToast("Endzeit muss nach Startzeit liegen", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createSchedule(newSchedule);
|
||||
newSchedule = {
|
||||
dayOfWeek: "",
|
||||
startTime: "",
|
||||
endTime: "",
|
||||
scheduleType: "lesson",
|
||||
title: "",
|
||||
};
|
||||
await loadSchedules();
|
||||
addToast("Eintrag erstellt", "success");
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (confirm("Wirklich löschen?")) {
|
||||
await deleteSchedule(id);
|
||||
await loadSchedules();
|
||||
addToast("Gelöscht", "success");
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (schedules.length === 0) return addToast("Keine Daten", "warning");
|
||||
const exportData = schedules.map((s) => ({ ...s, id: undefined }));
|
||||
const dataStr =
|
||||
"data:text/json;charset=utf-8," +
|
||||
encodeURIComponent(JSON.stringify(exportData, null, 2));
|
||||
const a = document.createElement("a");
|
||||
a.href = dataStr;
|
||||
a.download = "stundenplan_export.json";
|
||||
a.click();
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
event.target.value = "";
|
||||
if (!confirm("Import starten? Duplikate möglich.")) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const importedData = JSON.parse(e.target.result);
|
||||
importProcessing = true;
|
||||
addToast(`Importiere ${importedData.length}...`, "info");
|
||||
let count = 0;
|
||||
let errors = 0;
|
||||
for (const item of importedData) {
|
||||
if (
|
||||
item.dayOfWeek !== undefined &&
|
||||
isValidTimeRange(item.startTime, item.endTime)
|
||||
) {
|
||||
await createSchedule({
|
||||
dayOfWeek: String(item.dayOfWeek),
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
scheduleType: item.scheduleType || "lesson",
|
||||
title: item.title || "",
|
||||
});
|
||||
count++;
|
||||
} else errors++;
|
||||
}
|
||||
await loadSchedules();
|
||||
errors > 0
|
||||
? addToast(
|
||||
`${count} importiert, ${errors} ungültig`,
|
||||
"warning",
|
||||
)
|
||||
: addToast("Import erfolgreich", "success");
|
||||
} catch (err) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
importProcessing = false;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function toggleTargetDay(dayIndex) {
|
||||
const sIndex = String(dayIndex);
|
||||
copyTargetDays = copyTargetDays.includes(sIndex)
|
||||
? copyTargetDays.filter((d) => d !== sIndex)
|
||||
: [...copyTargetDays, sIndex];
|
||||
}
|
||||
|
||||
async function handleCopyDay() {
|
||||
if (copySourceDay === "" || copyTargetDays.length === 0) return;
|
||||
copyProcessing = true;
|
||||
try {
|
||||
const sourceEntries = schedules.filter(
|
||||
(s) => String(s.dayOfWeek) === copySourceDay,
|
||||
);
|
||||
if (sourceEntries.length === 0)
|
||||
throw new Error("Keine Quelleinträge");
|
||||
|
||||
if (deleteExisting) {
|
||||
const toDel = schedules.filter((s) =>
|
||||
copyTargetDays.includes(String(s.dayOfWeek)),
|
||||
);
|
||||
for (const s of toDel) await deleteSchedule(s.id);
|
||||
}
|
||||
for (const targetDay of copyTargetDays) {
|
||||
for (const entry of sourceEntries) {
|
||||
if (isValidTimeRange(entry.startTime, entry.endTime)) {
|
||||
await createSchedule({
|
||||
...entry,
|
||||
dayOfWeek: targetDay,
|
||||
id: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
addToast("Kopieren erfolgreich", "success");
|
||||
showCopyModal = false;
|
||||
copyTargetDays = [];
|
||||
copySourceDay = "";
|
||||
await loadSchedules();
|
||||
} catch (e) {
|
||||
addToast(e.message, "error");
|
||||
} finally {
|
||||
copyProcessing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="join">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
bind:this={fileInput}
|
||||
on:change={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
on:click={handleExport}
|
||||
disabled={schedules.length === 0}
|
||||
><i class="fas fa-download mr-2"></i> Export</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
on:click={triggerImport}
|
||||
disabled={importProcessing}
|
||||
><i class="fas fa-upload mr-2"></i> Import</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-info join-item"
|
||||
on:click={() => (showCopyModal = true)}
|
||||
><i class="fas fa-copy mr-2"></i> Tag kopieren</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-sm opacity-60 uppercase mb-2">
|
||||
Neuen Eintrag erstellen
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text font-bold">Wochentag</span></label
|
||||
>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={newSchedule.dayOfWeek}
|
||||
>
|
||||
<option value="">-- Wählen --</option>
|
||||
{#each dayNames as day, i}
|
||||
<option value={String(i)}>{day}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text font-bold">Startzeit</span></label
|
||||
>
|
||||
<input
|
||||
type="time"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newSchedule.startTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text font-bold">Endzeit</span></label
|
||||
>
|
||||
<input
|
||||
type="time"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newSchedule.endTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text font-bold">Typ</span></label
|
||||
>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={newSchedule.scheduleType}
|
||||
>
|
||||
<option value="lesson">Unterricht</option>
|
||||
<option value="break">Pause</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full md:col-span-2">
|
||||
<label class="label"
|
||||
><span class="label-text font-bold">Titel / Fach</span
|
||||
></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="z.B. Mathematik"
|
||||
bind:value={newSchedule.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button
|
||||
class="btn btn-primary px-8"
|
||||
on:click={handleCreate}
|
||||
disabled={!newSchedule.dayOfWeek || $loading}
|
||||
>
|
||||
{#if $loading}<span class="loading loading-spinner"></span>{/if}
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-x-auto bg-base-100 rounded-lg shadow-xl border border-base-200"
|
||||
>
|
||||
<table class="table table-zebra w-full">
|
||||
<thead
|
||||
><tr
|
||||
><th>Tag</th><th>Zeit</th><th>Typ</th><th>Titel</th><th
|
||||
>Aktion</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each schedules as s (s.id)}
|
||||
<tr>
|
||||
<td class="font-bold">{dayNames[s.dayOfWeek]}</td>
|
||||
<td>{s.startTime} - {s.endTime}</td>
|
||||
<td
|
||||
><span
|
||||
class="badge {s.scheduleType === 'break'
|
||||
? 'badge-ghost'
|
||||
: 'badge-primary'}"
|
||||
>{s.scheduleType === "break"
|
||||
? "Pause"
|
||||
: "Unterricht"}</span
|
||||
></td
|
||||
>
|
||||
<td>{s.title}</td>
|
||||
<td
|
||||
><button
|
||||
class="btn btn-xs btn-error btn-outline"
|
||||
on:click={() => handleDelete(s.id)}>Löschen</button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<dialog class="modal {showCopyModal ? 'modal-open' : ''}">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Tag kopieren</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text">Quelle</span></label
|
||||
>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={copySourceDay}
|
||||
>
|
||||
<option value="">Wählen...</option>
|
||||
{#each dayNames as day, i}
|
||||
<option value={String(i)}>{day}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text">Ziel-Tage</span></label
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each dayNames as day, i}
|
||||
{#if String(i) !== copySourceDay}
|
||||
<label
|
||||
class="cursor-pointer label border rounded-lg px-3 py-2 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm checkbox-primary mr-3"
|
||||
checked={copyTargetDays.includes(String(i))}
|
||||
on:change={() => toggleTargetDay(i)}
|
||||
/>
|
||||
<span class="label-text font-medium">{day}</span
|
||||
>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control bg-base-200 p-3 rounded-lg mt-4">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-error"
|
||||
bind:checked={deleteExisting}
|
||||
/>
|
||||
<span class="label-text"
|
||||
>Vorhandene Einträge am Ziel vorher löschen</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
class="btn"
|
||||
on:click={() => (showCopyModal = false)}
|
||||
disabled={copyProcessing}>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-success"
|
||||
on:click={handleCopyDay}
|
||||
disabled={!copySourceDay ||
|
||||
copyTargetDays.length === 0 ||
|
||||
copyProcessing}
|
||||
>
|
||||
{copyProcessing ? "Kopiere..." : "Kopieren starten"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
137
frontend/src/components/admin/AdminSchoolYearsTab.svelte
Normal file
137
frontend/src/components/admin/AdminSchoolYearsTab.svelte
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getSchoolYears,
|
||||
getActiveSchoolYear,
|
||||
createSchoolYear,
|
||||
activateSchoolYear,
|
||||
deleteSchoolYear,
|
||||
} from "../../lib/api";
|
||||
import { loading } from "../../lib/stores";
|
||||
|
||||
let schoolYears = [];
|
||||
let activeSchoolYear = null;
|
||||
let newYear = { name: "", startDate: "", endDate: "" };
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
async function loadData() {
|
||||
const [years, active] = await Promise.all([
|
||||
getSchoolYears(),
|
||||
getActiveSchoolYear().catch(() => null),
|
||||
]);
|
||||
schoolYears = years;
|
||||
activeSchoolYear = active;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
await createSchoolYear(newYear);
|
||||
newYear = { name: "", startDate: "", endDate: "" };
|
||||
await loadData();
|
||||
}
|
||||
async function handleActivate(id) {
|
||||
await activateSchoolYear(id);
|
||||
await loadData();
|
||||
}
|
||||
async function handleDelete(id) {
|
||||
if (confirm("Löschen?")) {
|
||||
await deleteSchoolYear(id);
|
||||
await loadData();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if activeSchoolYear}
|
||||
<div class="alert alert-info shadow-lg mb-6">
|
||||
<i class="fas fa-calendar-check"></i>
|
||||
<div>
|
||||
<h3 class="font-bold">
|
||||
Aktives Schuljahr: {activeSchoolYear.name}
|
||||
</h3>
|
||||
<div class="text-xs">
|
||||
{activeSchoolYear.startDate} bis {activeSchoolYear.endDate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-warning shadow-lg mb-6">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span>Kein Schuljahr aktiv! Bitte eines aktivieren.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||
<div class="form-control w-full">
|
||||
<label class="label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
placeholder="2024/2025"
|
||||
bind:value={newYear.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">Start</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={newYear.startDate}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">Ende</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={newYear.endDate}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={handleCreate}
|
||||
disabled={$loading}>Erstellen</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-xl">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead
|
||||
><tr
|
||||
><th>Name</th><th>Start</th><th>Ende</th><th>Status</th><th
|
||||
>Aktion</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each schoolYears as sy}
|
||||
<tr>
|
||||
<td class="font-bold">{sy.name}</td>
|
||||
<td>{sy.startDate}</td>
|
||||
<td>{sy.endDate}</td>
|
||||
<td>
|
||||
{#if sy.isActive}
|
||||
<span class="badge badge-success">Aktiv</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost">Inaktiv</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if !sy.isActive}
|
||||
<button
|
||||
class="btn btn-xs btn-info"
|
||||
on:click={() => handleActivate(sy.id)}
|
||||
>Aktivieren</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-xs btn-error btn-outline ml-2"
|
||||
on:click={() => handleDelete(sy.id)}>Löschen</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
154
frontend/src/components/admin/AdminSettingsTab.svelte
Normal file
154
frontend/src/components/admin/AdminSettingsTab.svelte
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<script>
|
||||
import { uploadLogo, getLicenseStatus, uploadLicense } from "../../lib/api";
|
||||
import { addToast } from "../../lib/stores";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let fileInput;
|
||||
let previewSrc = "/api/logo?t=" + Date.now();
|
||||
let uploading = false;
|
||||
let licenseStatus = null;
|
||||
let licFile;
|
||||
|
||||
onMount(async () => {
|
||||
loadLicense();
|
||||
});
|
||||
|
||||
async function loadLicense() {
|
||||
try {
|
||||
licenseStatus = await getLicenseStatus();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function handleLicenseUpload() {
|
||||
if (!licFile.files[0]) return;
|
||||
try {
|
||||
await uploadLicense(licFile.files[0]);
|
||||
addToast("Lizenzdatei hochgeladen", "success");
|
||||
loadLicense();
|
||||
} catch (e) {
|
||||
addToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileChange(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
addToast("Bitte nur Bilder hochladen", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
uploading = true;
|
||||
try {
|
||||
await uploadLogo(file);
|
||||
addToast("Logo aktualisiert", "success");
|
||||
const timestamp = Date.now();
|
||||
previewSrc = `/api/logo?t=${timestamp}`;
|
||||
|
||||
window.dispatchEvent(new Event("logo-updated"));
|
||||
} catch (err) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 max-w-2xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Schuleinstellungen</h3>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-bold">Schul-Logo</span>
|
||||
<span class="label-text-alt"
|
||||
>Wird im Login und Dashboard angezeigt</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-6 mt-2">
|
||||
<div
|
||||
class="avatar placeholder border border-base-300 rounded-lg p-1 bg-base-200"
|
||||
>
|
||||
<div class="w-24 h-24 rounded-lg">
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt="Logo"
|
||||
on:error={(e) => (e.target.style.display = "none")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png, image/jpeg"
|
||||
class="file-input file-input-bordered file-input-primary w-full max-w-xs"
|
||||
bind:this={fileInput}
|
||||
on:change={handleFileChange}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div class="text-xs text-base-content/50 mt-2">
|
||||
Empfohlen: PNG mit transparentem Hintergrund.<br />
|
||||
Max. 2MB.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 max-w-2xl mt-8">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Lizenzierung</h3>
|
||||
|
||||
{#if licenseStatus}
|
||||
<div
|
||||
class="stats stats-vertical lg:stats-horizontal shadow mb-4 border border-base-200"
|
||||
>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Status</div>
|
||||
<div
|
||||
class="stat-value text-lg {licenseStatus.is_valid
|
||||
? 'text-success'
|
||||
: 'text-error'}"
|
||||
>
|
||||
{licenseStatus.is_valid ? "Aktiv" : "Ungültig"}
|
||||
</div>
|
||||
<div class="stat-desc">{licenseStatus.message}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Schule</div>
|
||||
<div class="stat-value text-lg">
|
||||
{licenseStatus.school_name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Gültig bis</div>
|
||||
<div class="stat-value text-lg">
|
||||
{licenseStatus.expires_at || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-bold"
|
||||
>Lizenzdatei einspielen (.lic)</span
|
||||
>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="file"
|
||||
accept=".lic"
|
||||
class="file-input file-input-bordered w-full"
|
||||
bind:this={licFile}
|
||||
/>
|
||||
<button class="btn btn-primary" on:click={handleLicenseUpload}
|
||||
>Hochladen</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
417
frontend/src/components/admin/AdminSubstitutionsTab.svelte
Normal file
417
frontend/src/components/admin/AdminSubstitutionsTab.svelte
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getAllSubstitutions,
|
||||
createSubstitution,
|
||||
deleteSubstitution,
|
||||
getSchedules,
|
||||
} from "../../lib/api";
|
||||
import {
|
||||
getISOWeek,
|
||||
getISOYear,
|
||||
getDateOfISOWeek,
|
||||
formatDate,
|
||||
} from "../../lib/utils";
|
||||
import { addToast, loading } from "../../lib/stores";
|
||||
import ScheduleItem from "../ScheduleItem.svelte";
|
||||
|
||||
const today = new Date();
|
||||
let currentISOYear = getISOYear(today);
|
||||
let currentWeek = getISOWeek(today);
|
||||
|
||||
let substitutions = [];
|
||||
let schedules = [];
|
||||
|
||||
let showModal = false;
|
||||
let selectedSchedule = null;
|
||||
let form = { date: "", startTime: "", endTime: "", title: "", notes: "" };
|
||||
|
||||
$: weekDates = Array.from({ length: 5 }, (_, i) => {
|
||||
const d = getDateOfISOWeek(currentWeek, currentISOYear);
|
||||
d.setDate(d.getDate() + i);
|
||||
return {
|
||||
name: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"][
|
||||
i
|
||||
],
|
||||
date: formatDate(d),
|
||||
dayIndex: i,
|
||||
};
|
||||
});
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [subs, scheds] = await Promise.all([
|
||||
getAllSubstitutions(),
|
||||
getSchedules(),
|
||||
]);
|
||||
substitutions = subs;
|
||||
schedules = scheds;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function changeWeek(delta) {
|
||||
const d = getDateOfISOWeek(currentWeek, currentISOYear);
|
||||
d.setDate(d.getDate() + delta * 7);
|
||||
currentWeek = getISOWeek(d);
|
||||
currentISOYear = getISOYear(d);
|
||||
}
|
||||
|
||||
function openCreateModal(schedule, dateStr) {
|
||||
const existing = findSubstitution(dateStr, schedule.startTime);
|
||||
if (existing) {
|
||||
if (
|
||||
!confirm(
|
||||
"Für diesen Termin existiert bereits eine Vertretung. Trotzdem noch eine anlegen?",
|
||||
)
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
form = {
|
||||
title: "Vertretung: " + (schedule.title || "Unterricht"),
|
||||
date: dateStr,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
notes: "",
|
||||
scheduleId: schedule.id,
|
||||
};
|
||||
selectedSchedule = schedule;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.title || !form.date) return;
|
||||
try {
|
||||
await createSubstitution(form);
|
||||
addToast("Vertretung erfolgreich ausgeschrieben", "success");
|
||||
showModal = false;
|
||||
substitutions = await getAllSubstitutions();
|
||||
loadData();
|
||||
} catch (e) {
|
||||
addToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (
|
||||
!confirm(
|
||||
"Möchten Sie dieses Angebot wirklich löschen? Falls es schon übernommen wurde, wird es auch beim Mitarbeiter entfernt.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await deleteSubstitution(id);
|
||||
addToast("Eintrag gelöscht", "success");
|
||||
substitutions = await getAllSubstitutions();
|
||||
} catch (e) {
|
||||
addToast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function findSubstitution(date, startTime) {
|
||||
return substitutions.find(
|
||||
(s) => s.date === date && s.start_time === startTime,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-8 fade-in">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="card-title">Vertretung planen</h3>
|
||||
|
||||
<div class="flex items-center gap-4 bg-base-200 rounded-lg p-1">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost btn-circle"
|
||||
on:click={() => changeWeek(-1)}>❮</button
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="font-bold">KW {currentWeek}</div>
|
||||
<div class="text-xs opacity-50">{currentISOYear}</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost btn-circle"
|
||||
on:click={() => changeWeek(1)}>❯</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm opacity-70 mb-4">
|
||||
Klicken Sie auf eine Stunde, um eine Vertretung auszuschreiben.
|
||||
Bereits ausgeschriebene Vertretungen werden farbig markiert.
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto border border-base-200 rounded-xl">
|
||||
<table class="table table-fixed w-full min-w-[800px]">
|
||||
<thead>
|
||||
<tr class="bg-base-200/50">
|
||||
{#each weekDates as day}
|
||||
<th class="text-center py-3">
|
||||
<div>{day.name}</div>
|
||||
<div class="font-normal text-xs opacity-50">
|
||||
{day.date}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{#each weekDates as day}
|
||||
<td
|
||||
class="align-top p-2 border-r border-base-200 last:border-0 h-40"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
|
||||
{@const sub = findSubstitution(
|
||||
day.date,
|
||||
schedule.startTime,
|
||||
)}
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() =>
|
||||
openCreateModal(
|
||||
schedule,
|
||||
day.date,
|
||||
)}
|
||||
class="relative cursor-pointer hover:scale-[1.02] transition-transform group"
|
||||
>
|
||||
<ScheduleItem
|
||||
{schedule}
|
||||
dayOfWeek={day.dayIndex}
|
||||
isClickable={false}
|
||||
isSelected={false}
|
||||
/>
|
||||
|
||||
{#if sub}
|
||||
<div
|
||||
class="absolute inset-0 bg-base-100/90 rounded-lg border-2
|
||||
{sub.taken_by_user_id
|
||||
? 'border-success'
|
||||
: 'border-warning'}
|
||||
flex flex-col items-center justify-center text-center p-1 shadow-lg"
|
||||
>
|
||||
{#if sub.taken_by_user_id}
|
||||
<div
|
||||
class="badge badge-success badge-sm mb-1"
|
||||
>
|
||||
Übernommen
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-bold truncate w-full"
|
||||
>
|
||||
{sub.taken_by_username}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="badge badge-warning badge-sm mb-1"
|
||||
>
|
||||
Gesucht
|
||||
</div>
|
||||
<div
|
||||
class="text-xs opacity-70"
|
||||
>
|
||||
Offen
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="text-[10px] mt-1 opacity-50 truncate w-full"
|
||||
>
|
||||
{sub.title}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !sub}
|
||||
<div
|
||||
class="absolute inset-0 bg-primary/10 rounded-lg opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||||
>
|
||||
<span
|
||||
class="badge badge-primary"
|
||||
>+ Erstellen</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if schedules.filter((s) => s.dayOfWeek === day.dayIndex).length === 0}
|
||||
<div
|
||||
class="text-center text-xs opacity-30 mt-4"
|
||||
>
|
||||
- Frei -
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Liste aller Vertretungen</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Zeit</th>
|
||||
<th>Titel / Notiz</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each substitutions as s}
|
||||
<tr class="hover group">
|
||||
<td class="font-mono text-sm">{s.date}</td>
|
||||
<td>{s.start_time} - {s.end_time}</td>
|
||||
<td>
|
||||
<div class="font-bold">{s.title}</div>
|
||||
{#if s.notes}<div
|
||||
class="text-xs opacity-50"
|
||||
>
|
||||
{s.notes}
|
||||
</div>{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if s.taken_by_user_id}
|
||||
<span class="badge badge-success gap-2"
|
||||
>✓ {s.taken_by_username}</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="badge badge-warning badge-outline"
|
||||
>Offen</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
on:click={() => handleDelete(s.id)}
|
||||
title="Löschen"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr
|
||||
><td
|
||||
colspan="5"
|
||||
class="text-center opacity-50 py-8"
|
||||
>Keine Einträge</td
|
||||
></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog class="modal {showModal ? 'modal-open' : ''}">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Vertretung ausschreiben</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
bind:value={form.title}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={form.date}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">Zeitraum</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={form.startTime}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="time"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={form.endTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">Notizen</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered h-24"
|
||||
bind:value={form.notes}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn" on:click={() => (showModal = false)}
|
||||
>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
on:click={handleCreate}
|
||||
disabled={$loading}
|
||||
>
|
||||
{#if $loading}<span class="loading loading-spinner"></span>{/if}
|
||||
Veröffentlichen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
312
frontend/src/components/admin/AdminTimeEntriesTab.svelte
Normal file
312
frontend/src/components/admin/AdminTimeEntriesTab.svelte
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getAllTimeEntries,
|
||||
getUsers,
|
||||
createAdminTimeEntry,
|
||||
updateTimeEntry,
|
||||
deleteTimeEntry,
|
||||
getYearlySummary,
|
||||
downloadYearlySummaryPDF,
|
||||
} from "../../lib/api";
|
||||
import { calculateHours } from "../../lib/utils";
|
||||
import { loading, addToast } from "../../lib/stores";
|
||||
|
||||
let timeEntries = [];
|
||||
let users = [];
|
||||
let yearlySummary = [];
|
||||
let manualEntry = {
|
||||
selectedUserId: "",
|
||||
date: "",
|
||||
hours: "",
|
||||
type: "manual",
|
||||
};
|
||||
let editingEntryId = null;
|
||||
let editForm = {};
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadEntries(), loadUsers(), loadSummary()]);
|
||||
});
|
||||
async function loadEntries() {
|
||||
timeEntries = await getAllTimeEntries();
|
||||
}
|
||||
async function loadUsers() {
|
||||
users = await getUsers();
|
||||
}
|
||||
async function loadSummary() {
|
||||
yearlySummary = await getYearlySummary();
|
||||
}
|
||||
|
||||
async function handleManualEntry() {
|
||||
if (
|
||||
!manualEntry.selectedUserId ||
|
||||
!manualEntry.date ||
|
||||
!manualEntry.hours
|
||||
) {
|
||||
addToast("Bitte alle Felder ausfüllen", "warning");
|
||||
return;
|
||||
}
|
||||
await createAdminTimeEntry({
|
||||
...manualEntry,
|
||||
hours: parseFloat(manualEntry.hours),
|
||||
});
|
||||
manualEntry.hours = "";
|
||||
await loadEntries();
|
||||
await loadSummary();
|
||||
addToast("Gebucht", "success");
|
||||
}
|
||||
|
||||
async function handlePdfDownload() {
|
||||
try {
|
||||
const blob = await downloadYearlySummaryPDF();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `Jahresuebersicht-${new Date().getFullYear()}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} catch (e) {
|
||||
addToast("PDF Fehler", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(entry) {
|
||||
editingEntryId = entry.id;
|
||||
editForm = { ...entry };
|
||||
}
|
||||
async function saveEdit() {
|
||||
await updateTimeEntry(editingEntryId, editForm);
|
||||
editingEntryId = null;
|
||||
await loadEntries();
|
||||
loadSummary();
|
||||
}
|
||||
async function deleteEntry(id) {
|
||||
if (confirm("Löschen?")) {
|
||||
await deleteTimeEntry(id);
|
||||
await loadEntries();
|
||||
loadSummary();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4"
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary w-full sm:w-auto"
|
||||
on:click={handlePdfDownload}
|
||||
disabled={$loading}
|
||||
>
|
||||
<i class="fas fa-file-pdf mr-2"></i> PDF Bericht
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8 border border-base-200">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h3 class="card-title text-sm opacity-60 uppercase mb-4">
|
||||
Jahresübersicht
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full whitespace-nowrap">
|
||||
<thead
|
||||
><tr
|
||||
><th>Mitarbeiter</th><th class="text-right">Soll</th><th
|
||||
class="text-right">Ist</th
|
||||
><th class="text-right">Differenz</th><th>Status</th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each yearlySummary as s}
|
||||
<tr>
|
||||
<td class="font-bold">{s.username}</td>
|
||||
<td class="text-right">{s.yearlyTarget}</td>
|
||||
<td class="text-right">{s.yearlyActual}</td>
|
||||
<td
|
||||
class="text-right font-mono {s.remainingYearly >
|
||||
0
|
||||
? 'text-warning'
|
||||
: 'text-success'}"
|
||||
>{s.remainingYearly.toFixed(1)}</td
|
||||
>
|
||||
<td>
|
||||
{#if s.remainingYearly > 0}
|
||||
<span class="badge badge-warning badge-sm"
|
||||
>Offen</span
|
||||
>
|
||||
{:else}
|
||||
<span class="badge badge-success badge-sm"
|
||||
>Erfüllt</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-lg mb-8">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h3 class="card-title text-sm uppercase opacity-70">
|
||||
Manuelle Korrektur / Eintragung
|
||||
</h3>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end"
|
||||
>
|
||||
<div class="form-control">
|
||||
<label class="label"
|
||||
><span class="label-text">Mitarbeiter</span></label
|
||||
>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={manualEntry.selectedUserId}
|
||||
>
|
||||
<option value="">Wählen...</option>
|
||||
{#each users as u}
|
||||
<option value={u.id}>{u.username}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"
|
||||
><span class="label-text">Datum</span></label
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={manualEntry.date}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"
|
||||
><span class="label-text">Stunden (+/-)</span></label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={manualEntry.hours}
|
||||
placeholder="-1.5 od. 2.0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-info w-full"
|
||||
on:click={handleManualEntry}
|
||||
disabled={!manualEntry.selectedUserId || $loading}
|
||||
>
|
||||
Buchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-x-auto bg-base-100 rounded-lg shadow-xl border border-base-200"
|
||||
>
|
||||
<table class="table table-zebra w-full whitespace-nowrap">
|
||||
<thead
|
||||
><tr
|
||||
><th>User</th><th>Datum</th><th>Zeit</th><th>Typ</th><th
|
||||
>Stunden</th
|
||||
><th>Aktion</th></tr
|
||||
></thead
|
||||
>
|
||||
<tbody>
|
||||
{#each timeEntries as entry (entry.id)}
|
||||
{#if editingEntryId === entry.id}
|
||||
<tr class="bg-base-200">
|
||||
<td>{entry.username}</td>
|
||||
<td
|
||||
><input
|
||||
type="date"
|
||||
class="input input-xs input-bordered w-24"
|
||||
bind:value={editForm.date}
|
||||
/></td
|
||||
>
|
||||
<td>
|
||||
<div class="flex flex-col gap-1">
|
||||
<input
|
||||
type="time"
|
||||
class="input input-xs input-bordered"
|
||||
bind:value={editForm.startTime}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
class="input input-xs input-bordered"
|
||||
bind:value={editForm.endTime}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="select select-bordered select-xs w-20"
|
||||
bind:value={editForm.entryType}
|
||||
>
|
||||
<option value="lesson">Unt.</option>
|
||||
<option value="break">Pause</option>
|
||||
<option value="manual">Man.</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-xs btn-success join-item"
|
||||
on:click={saveEdit}
|
||||
><i class="fas fa-check"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost join-item"
|
||||
on:click={() => (editingEntryId = null)}
|
||||
><i class="fas fa-times"></i></button
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="font-bold">{entry.username}</td>
|
||||
<td>{entry.date}</td>
|
||||
<td>{entry.startTime} - {entry.endTime}</td>
|
||||
<td>
|
||||
<span
|
||||
class="badge badge-sm {entry.entryType ===
|
||||
'manual'
|
||||
? 'badge-info'
|
||||
: 'badge-ghost'}"
|
||||
>
|
||||
{entry.entryType}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-mono font-bold"
|
||||
>{entry.entryType === "lesson"
|
||||
? "1.0"
|
||||
: calculateHours(
|
||||
entry.startTime,
|
||||
entry.endTime,
|
||||
)}</td
|
||||
>
|
||||
<td>
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost join-item"
|
||||
on:click={() => startEdit(entry)}
|
||||
>Edit</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost text-error join-item"
|
||||
on:click={() => deleteEntry(entry.id)}
|
||||
>Del</button
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
209
frontend/src/components/admin/AdminUsersTab.svelte
Normal file
209
frontend/src/components/admin/AdminUsersTab.svelte
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
updateUserWorkHours,
|
||||
resetUserPassword,
|
||||
} from "../../lib/api";
|
||||
import { loading, addToast } from "../../lib/stores";
|
||||
|
||||
let users = [];
|
||||
let newUser = { username: "", password: "", isAdmin: false };
|
||||
let editingUserId = null;
|
||||
let editingWorkHours = "";
|
||||
let resetPasswordUserId = null;
|
||||
let resetPasswordNew = "";
|
||||
|
||||
onMount(async () => (users = await getUsers()));
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newUser.username || !newUser.password) {
|
||||
addToast("Benutzername und Passwort pflicht", "warning");
|
||||
return;
|
||||
}
|
||||
await createUser(newUser);
|
||||
newUser = { username: "", password: "", isAdmin: false };
|
||||
users = await getUsers();
|
||||
addToast("User angelegt", "success");
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (confirm("Löschen?")) {
|
||||
await deleteUser(id);
|
||||
users = await getUsers();
|
||||
addToast("Gelöscht", "success");
|
||||
}
|
||||
}
|
||||
|
||||
function startEditHours(user) {
|
||||
editingUserId = user.id;
|
||||
editingWorkHours = String(user.yearlyWorkHours);
|
||||
resetPasswordUserId = null;
|
||||
}
|
||||
async function saveWorkHours() {
|
||||
if (editingUserId) {
|
||||
await updateUserWorkHours(editingUserId, editingWorkHours);
|
||||
editingUserId = null;
|
||||
users = await getUsers();
|
||||
addToast("Gespeichert", "success");
|
||||
}
|
||||
}
|
||||
|
||||
function startResetPassword(user) {
|
||||
resetPasswordUserId = user.id;
|
||||
resetPasswordNew = "";
|
||||
editingUserId = null;
|
||||
}
|
||||
async function savePassword() {
|
||||
if (resetPasswordUserId && resetPasswordNew) {
|
||||
await resetUserPassword(resetPasswordUserId, resetPasswordNew);
|
||||
resetPasswordUserId = null;
|
||||
addToast("Passwort geändert", "success");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl mb-8 border border-base-200">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h3 class="card-title text-sm opacity-60 uppercase mb-2">
|
||||
Neuen Benutzer anlegen
|
||||
</h3>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end"
|
||||
>
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text">Benutzername</span></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newUser.username}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label"
|
||||
><span class="label-text">Passwort</span></label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newUser.password}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label
|
||||
class="label cursor-pointer justify-start gap-4 h-full items-center"
|
||||
>
|
||||
<span class="label-text">Admin-Rechte</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
bind:checked={newUser.isAdmin}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
on:click={handleCreate}
|
||||
disabled={$loading}>Anlegen</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-x-auto bg-base-100 rounded-lg shadow-xl border border-base-200"
|
||||
>
|
||||
<table class="table table-zebra w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr
|
||||
><th>ID</th><th>Name</th><th>Rolle</th><th>Jahresstunden</th><th
|
||||
>Aktionen</th
|
||||
></tr
|
||||
>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr>
|
||||
<td class="opacity-50 text-xs">{user.id}</td>
|
||||
<td class="font-bold">{user.username}</td>
|
||||
<td>
|
||||
{#if user.isAdmin}<span
|
||||
class="badge badge-error badge-sm">Admin</span
|
||||
>
|
||||
{:else}<span class="badge badge-ghost badge-sm"
|
||||
>User</span
|
||||
>{/if}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{#if editingUserId === user.id}
|
||||
<div class="join">
|
||||
<input
|
||||
class="input input-sm input-bordered join-item w-16"
|
||||
type="number"
|
||||
step="0.5"
|
||||
bind:value={editingWorkHours}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm btn-success join-item"
|
||||
on:click={saveWorkHours}>✓</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost join-item"
|
||||
on:click={() => (editingUserId = null)}
|
||||
>✕</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
{user.yearlyWorkHours} h
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{#if resetPasswordUserId === user.id}
|
||||
<div class="join">
|
||||
<input
|
||||
class="input input-sm input-bordered join-item w-24"
|
||||
type="password"
|
||||
placeholder="Neues PW"
|
||||
bind:value={resetPasswordNew}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm btn-success join-item"
|
||||
on:click={savePassword}>OK</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost join-item"
|
||||
on:click={() =>
|
||||
(resetPasswordUserId = null)}>✕</button
|
||||
>
|
||||
</div>
|
||||
{:else if user.id !== 1}
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-xs btn-outline join-item"
|
||||
on:click={() => startEditHours(user)}
|
||||
>Std.</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs btn-warning join-item"
|
||||
on:click={() => startResetPassword(user)}
|
||||
>PW</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs btn-error join-item"
|
||||
on:click={() => handleDelete(user.id)}
|
||||
>Del</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
352
frontend/src/lib/api.js
Normal file
352
frontend/src/lib/api.js
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
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");
|
||||
};
|
||||
82
frontend/src/lib/stores.js
Normal file
82
frontend/src/lib/stores.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { writable, get } from "svelte/store";
|
||||
|
||||
function safeParse(jsonString) {
|
||||
if (!jsonString || jsonString === "undefined" || jsonString === "null")
|
||||
return null;
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
try {
|
||||
const base64Url = token.split(".")[1];
|
||||
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const jsonPayload = decodeURIComponent(
|
||||
window
|
||||
.atob(base64)
|
||||
.split("")
|
||||
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join(""),
|
||||
);
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeUser = (u) => {
|
||||
if (!u) return null;
|
||||
return { ...u, isAdmin: !!(u.isAdmin || u.is_admin) };
|
||||
};
|
||||
|
||||
const storedToken = localStorage.getItem("token");
|
||||
let initialUser = normalizeUser(safeParse(localStorage.getItem("user")));
|
||||
let initialAuth = false;
|
||||
|
||||
if (storedToken) {
|
||||
const decoded = decodeJwt(storedToken);
|
||||
const currentTime = Date.now() / 1000;
|
||||
|
||||
if (decoded && decoded.exp && decoded.exp < currentTime) {
|
||||
console.warn("Token im Storage ist abgelaufen. Auto-Logout.");
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
initialUser = null;
|
||||
} else {
|
||||
initialAuth = !!initialUser;
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = writable({
|
||||
token: initialAuth ? storedToken : null,
|
||||
user: initialUser,
|
||||
isAuthenticated: initialAuth,
|
||||
});
|
||||
|
||||
auth.subscribe((value) => {
|
||||
if (value.token && value.user) {
|
||||
localStorage.setItem("token", value.token);
|
||||
localStorage.setItem("user", JSON.stringify(value.user));
|
||||
} else {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
}
|
||||
});
|
||||
|
||||
export const loading = writable(false);
|
||||
|
||||
export const toasts = writable([]);
|
||||
|
||||
export function addToast(message, type = "info") {
|
||||
const id = Date.now() + Math.random();
|
||||
const newToast = { id, message, type };
|
||||
toasts.update((all) => [newToast, ...all]);
|
||||
setTimeout(() => removeToast(id), 5000);
|
||||
}
|
||||
|
||||
export function removeToast(id) {
|
||||
toasts.update((all) => all.filter((t) => t.id !== id));
|
||||
}
|
||||
59
frontend/src/lib/utils.js
Normal file
59
frontend/src/lib/utils.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export function calculateHours(startTime, endTime) {
|
||||
if (!startTime || !endTime) return 0;
|
||||
if (endTime === "manual") return parseFloat(startTime) || 0;
|
||||
|
||||
const parseTime = (timeStr) => {
|
||||
const parts = timeStr.split(":");
|
||||
if (parts.length !== 2) return 0;
|
||||
return parseFloat(parts[0]) + parseFloat(parts[1]) / 60;
|
||||
};
|
||||
|
||||
const start = parseTime(startTime);
|
||||
const end = parseTime(endTime);
|
||||
|
||||
if (end > start) return end - start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getISOWeek(date) {
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
|
||||
);
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
export function getISOYear(date) {
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
|
||||
);
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
return d.getUTCFullYear();
|
||||
}
|
||||
|
||||
export function getDateOfISOWeek(w, y) {
|
||||
const simple = new Date(y, 0, 1 + (w - 1) * 7);
|
||||
const dow = simple.getDay();
|
||||
const ISOweekStart = simple;
|
||||
if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1);
|
||||
else ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay());
|
||||
return ISOweekStart;
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export const dayNames = [
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
];
|
||||
9
frontend/src/main.js
Normal file
9
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { mount } from "svelte";
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById("app"),
|
||||
});
|
||||
|
||||
export default app;
|
||||
8
frontend/svelte.config.js
Normal file
8
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("daisyui")],
|
||||
daisyui: {
|
||||
themes: ["light", "dark"],
|
||||
},
|
||||
};
|
||||
15
frontend/vite.config.js
Normal file
15
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8085",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue