Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
861062b320 docs: update README 2026-01-15 16:09:59 +01:00
59b37dc995 feat: update Dockerfile and docker-compose to work with new svelte frontend 2026-01-15 15:43:03 +01:00
e719f4565f feat: add completed Web-Frontend in Svelte with some new Features
- Added import of Schedules
- Added export for schedule table
- Added import of logo
- Added password change to users
- improved ui/ux
2026-01-15 15:19:53 +01:00
5788d8c767 refactor: remove elm files 2026-01-15 15:11:06 +01:00
65 changed files with 4318 additions and 5599 deletions

View file

@ -1,51 +1,34 @@
# Build stage for Elm frontend FROM node:22-alpine AS frontend-builder
FROM node:25-alpine AS elm-build
WORKDIR /frontend WORKDIR /src/frontend
# Install Elm COPY frontend/package.json frontend/package-lock.json ./
RUN npm install -g elm@latest-0.19.1 RUN npm ci
# Copy Elm files COPY frontend/ ./
COPY frontend/elm.json . RUN npm run build
COPY frontend/src ./src
# Build Elm app FROM golang:1.25.5-alpine AS backend-builder
RUN elm make src/Main.elm --optimize --output=elm.js
# Build stage for Go backend WORKDIR /src/backend
FROM golang:1.25.3-alpine AS go-build
WORKDIR /app
# Copy go mod files
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
# Copy backend source
COPY backend/ ./ COPY backend/ ./
# Build Go binary COPY --from=frontend-builder /src/frontend/dist ./dist
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o timetracker .
# Final stage
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/ WORKDIR /app
# Copy Go binary from build stage COPY --from=backend-builder /src/backend/timetracker .
COPY --from=go-build /app/main .
# Create static directory
RUN mkdir -p /root/static
# Copy Elm build artifacts
COPY --from=elm-build /frontend/elm.js /root/static/
COPY frontend/public/index.html /root/static/
# Create volume for database
VOLUME ["/data"] VOLUME ["/data"]
ENV PORT=8080 ENV PORT=8080
@ -53,4 +36,4 @@ ENV DB_PATH=/data/timetracking.db
EXPOSE 8080 EXPOSE 8080
CMD ["./main"] CMD ["./timetracker"]

View file

@ -65,8 +65,9 @@ Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Au
### Frontend ### Frontend
- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI - **Svelte 5**: Reaktivität und Performance.
- **Bulma CSS**: Modernes CSS-Framework - **Vite**: Build-Tooling.
- **Tailwind CSS + DaisyUI**: UI-Komponenten.
- **Font Awesome**: Icons - **Font Awesome**: Icons
- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung - **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 ### Für lokale Entwicklung
- Go 1.21+ - Go 1.25+
- Elm 0.19 - Node.js 20+
- Node.js 16+ (für Elm-Tooling)
- SQLite3 - SQLite3
## 🚀 Installation ## 🚀 Installation
@ -770,6 +770,6 @@ Todo
--- ---
**Version**: 1.5.0 **Version**: 1.7.0
**Letztes Update**: November 2025 **Letztes Update**: Januar 2026
**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter **Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter

View file

@ -14,20 +14,45 @@ import (
) )
func InitDB(filepath string) *sql.DB { 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 { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(time.Hour)
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
createTables(db) createTables(db)
createIndexes(db) createIndexes(db)
ensureAdminExists(db)
return db return db
} }
func ensureAdminExists(db *sql.DB) {
var count int
db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count == 0 {
log.Println("Keine Benutzer gefunden. Erstelle Standard-Admin...")
pw, _ := bcrypt.GenerateFromPassword([]byte("admin123"), 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) { func createTables(db *sql.DB) {
queries := []string{ queries := []string{
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
@ -56,58 +81,35 @@ func createTables(db *sql.DB) {
start_time TEXT NOT NULL, start_time TEXT NOT NULL,
end_time TEXT NOT NULL, end_time TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY (schedule_id) REFERENCES schedules(id) 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
)`, )`,
`CREATE TABLE IF NOT EXISTS school_years ( `CREATE TABLE IF NOT EXISTS school_years (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
start_date DATE NOT NULL, start_date TEXT NOT NULL,
end_date DATE NOT NULL, end_date TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
} }
for _, query := range queries { for _, query := range queries {
if _, err := db.Exec(query); err != nil { _, err := db.Exec(query)
log.Fatal(err) if err != nil {
log.Fatalf("Error creating table: %s\nQuery: %s", err, query)
} }
} }
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
_, err := db.Exec(`
INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
VALUES (?, ?, ?, ?, ?)`,
1, "admin", string(hash), true, 40.0,
)
if err != nil {
log.Fatal(err)
}
} }
func createIndexes(db *sql.DB) { func createIndexes(db *sql.DB) {
indexes := []string{ indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`, "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)",
`CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`, "CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)",
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`, "CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
`CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
`CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
} }
for _, idx := range indexes { for _, idx := range indexes {
if _, err := db.Exec(idx); err != nil { db.Exec(idx)
log.Printf("Warning: Failed to create index: %v", err)
}
} }
} }

View file

@ -3,8 +3,10 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -726,3 +728,74 @@ func (app *App) DeleteSchoolYearHandler(c echo.Context) error {
return c.NoContent(http.StatusNoContent) 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(&currentHash)
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"})
}

View file

@ -11,7 +11,7 @@ else
fi fi
if [ -z "$PORT" ]; then if [ -z "$PORT" ]; then
export PORT=8080 export PORT=8085
fi fi
if [ -z "$DB_PATH" ]; then if [ -z "$DB_PATH" ]; then

View file

@ -1,6 +1,10 @@
package main package main
import ( import (
"embed"
"fmt"
"io"
"io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -10,6 +14,9 @@ import (
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
) )
//go:embed dist
var frontendDist embed.FS
func main() { func main() {
dbPath := os.Getenv("DB_PATH") dbPath := os.Getenv("DB_PATH")
if dbPath == "" { if dbPath == "" {
@ -26,14 +33,15 @@ func main() {
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) e.Use(middleware.Recover())
// CORS Configuration e.Use(middleware.Gzip())
allowOrigins := []string{"*"} // Default for development
e.Use(middleware.Secure())
allowOrigins := []string{"*"}
if os.Getenv("ENVIRONMENT") == "production" { if os.Getenv("ENVIRONMENT") == "production" {
origins := os.Getenv("CORS_ALLOWED_ORIGINS") origins := os.Getenv("CORS_ALLOWED_ORIGINS")
if origins != "" { if origins != "" {
allowOrigins = strings.Split(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.HTTPErrorHandler = customHTTPErrorHandler
e.POST("/api/login", app.LoginHandler) e.POST("/api/login", app.LoginHandler)
e.GET("/api/logo", app.GetLogoHandler)
protected := e.Group("/api") protected := e.Group("/api")
protected.Use(JWTMiddleware()) protected.Use(JWTMiddleware())
@ -59,6 +68,7 @@ func main() {
protected.GET("/week-has-entries", app.CheckWeekHasEntries) protected.GET("/week-has-entries", app.CheckWeekHasEntries)
protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler) protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
protected.GET("/my-info", app.GetMyInfoHandler) protected.GET("/my-info", app.GetMyInfoHandler)
protected.POST("/change-password", app.ChangeMyPasswordHandler)
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler) protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
} }
@ -83,13 +93,38 @@ func main() {
admin.DELETE("/school-years/:id", app.DeleteSchoolYearHandler) admin.DELETE("/school-years/:id", app.DeleteSchoolYearHandler)
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler) admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler) admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
admin.POST("/settings/logo", app.UploadLogoHandler)
} }
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") port := os.Getenv("PORT")
if port == "" { if port == "" {
port = "8080" port = "8085"
} }
log.Printf("Server starting on port %s", port) log.Printf("Server starting on port %s", port)
@ -102,16 +137,9 @@ func customHTTPErrorHandler(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok { if he, ok := err.(*echo.HTTPError); ok {
code = he.Code code = he.Code
message = he.Message.(string) message = fmt.Sprintf("%v", he.Message)
} }
if !c.Response().Committed { c.Logger().Error(err)
if c.Request().Method == http.MethodHead { c.JSON(code, map[string]string{"message": message})
c.NoContent(code)
} else {
c.JSON(code, map[string]string{
"error": message,
})
}
}
} }

View file

@ -23,10 +23,10 @@ type WeeklyHours struct {
Week int `json:"week"` Week int `json:"week"`
Year int `json:"year"` Year int `json:"year"`
TotalHours float64 `json:"total_hours"` TotalHours float64 `json:"total_hours"`
YearlyTarget float64 `json:"yearly_target"` // NEU YearlyTarget float64 `json:"yearly_target"`
YearlyActual float64 `json:"yearly_actual"` // NEU YearlyActual float64 `json:"yearly_actual"`
WeeklyTarget float64 `json:"weekly_target"` // NEU WeeklyTarget float64 `json:"weekly_target"`
RemainingYearly float64 `json:"remaining_yearly"` // NEU RemainingYearly float64 `json:"remaining_yearly"`
} }
type User struct { type User struct {
@ -101,3 +101,8 @@ type Claims struct {
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type ChangePasswordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}

View file

@ -2,23 +2,18 @@ services:
timetracking: timetracking:
build: . build: .
container_name: school-timetracking container_name: school-timetracking
restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
- PORT=8080 - PORT=8080
- ENVIRONMENT=production
- DB_PATH=/data/timetracking.db - DB_PATH=/data/timetracking.db
- JWT_SECRET=your-default-secret-change-me - JWT_SECRET=change-me-to-something-secure-and-long
- TZ=Europe/Berlin # Optional: Zeitzone - TZ=Europe/Berlin
- CORS_ALLOWED_ORIGINS=http://localhost:8080
volumes: volumes:
- timetracking-data:/data - timetracking_data:/data
restart: unless-stopped
networks:
- timetracking-net
volumes: volumes:
timetracking-data: timetracking_data:
driver: local
networks:
timetracking-net:
driver: bridge

View file

@ -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": {}
}
}

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!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>

33
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,33 @@
{
"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

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View 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"
}
}

View file

@ -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
View 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

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

60
frontend/src/App.svelte Normal file
View file

@ -0,0 +1,60 @@
<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 ? 'night' : 'winter';
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>

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -1,17 +0,0 @@
module Types.Page exposing
( AdminTab(..)
, Page(..)
)
type Page
= LoginPage
| UserDashboard
| AdminDashboard
type AdminTab
= ScheduleTab
| UsersTab
| TimeEntriesTab
| SchoolYearsTab

View file

@ -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 ()
)

View file

@ -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 )

View file

@ -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 )

View file

@ -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 )

View file

@ -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
)

View file

@ -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 )

View file

@ -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 )

View file

@ -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 ())

View file

@ -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

View file

@ -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

View file

@ -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)
]

View file

@ -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) ]
]

View file

@ -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 ""
]

View file

@ -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" ]
]
]
]
]
]
]
]

View file

@ -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..." ]
]

View file

@ -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
View file

@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

View 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

View file

@ -0,0 +1,188 @@
<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';
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';
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 />
{/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 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>

View file

@ -0,0 +1,102 @@
<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>

View 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();
$: 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>

View file

@ -0,0 +1,39 @@
<script>
import { createEventDispatcher } from 'svelte';
export let schedule;
export let dayOfWeek;
export let isSelected = false;
export let isClickable = true;
const dispatch = createEventDispatcher();
// Style-Logik basierend auf dem Status
$: boxClass = isSelected
? "box has-background-success-light"
: (isClickable ? "box has-background-white" : "box has-background-light");
$: cursorStyle = isClickable ? "pointer" : "not-allowed";
// Opazität: Wenn nicht klickbar und nicht ausgewählt -> ausgegraut
$: opacity = (isClickable || isSelected) ? "1" : "0.6";
// Rahmen: Wenn klickbar (Hover-Effekt Visualisierung) vs fest
$: 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>

View file

@ -0,0 +1,34 @@
<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>

View file

@ -0,0 +1,445 @@
<script>
import { onMount } from 'svelte';
import { auth, addToast } from '../lib/stores';
import { logout, getSchedules, getMyTimeEntries, saveTimeEntriesBatch, deleteWeekEntries, changeMyPassword } 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: "" };
$: 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;
await loadData();
} catch (e) {
console.error(e);
} finally {
processing = false;
}
}
async function loadData() {
isLoadingData = true;
try {
const [schedulesData, entriesData] = await Promise.all([
getSchedules(),
getMyTimeEntries()
]);
schedules = schedulesData;
allEntries = entriesData;
filterEntries(entriesData);
} finally {
isLoadingData = false;
}
}
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">Stundenplan</li>
</ul>
</div>
<span class="font-bold text-lg sm:hidden">KW {currentWeek}</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">
<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}
</div>
{#if (!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>
<a class="active bg-primary/10 text-primary" href="#" on:click={() => 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
</a>
</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>

View file

@ -0,0 +1,253 @@
<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>

View file

@ -0,0 +1,86 @@
<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>

View file

@ -0,0 +1,68 @@
<script>
import { uploadLogo } from '../../lib/api';
import { addToast } from '../../lib/stores';
let fileInput;
let previewSrc = "/api/logo?t=" + Date.now();
let uploading = false;
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>

View file

@ -0,0 +1,157 @@
<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>

View file

@ -0,0 +1,120 @@
<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>

221
frontend/src/lib/api.js Normal file
View file

@ -0,0 +1,221 @@
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 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 });

View file

@ -0,0 +1,71 @@
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));
}

51
frontend/src/lib/utils.js Normal file
View file

@ -0,0 +1,51 @@
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
View 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;

View 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(),
}

View file

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{html,js,svelte,ts}",
],
theme: {
extend: {},
},
plugins: [
require('daisyui'),
],
daisyui: {
themes: ["light", "dark"],
},
}

18
frontend/vite.config.js Normal file
View file

@ -0,0 +1,18 @@
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
}
}
}
})