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
This commit is contained in:
parent
5788d8c767
commit
e719f4565f
32 changed files with 4290 additions and 87 deletions
|
|
@ -14,20 +14,45 @@ import (
|
|||
)
|
||||
|
||||
func InitDB(filepath string) *sql.DB {
|
||||
db, err := sql.Open("sqlite", filepath)
|
||||
dsn := filepath + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
createTables(db)
|
||||
createIndexes(db)
|
||||
|
||||
ensureAdminExists(db)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func ensureAdminExists(db *sql.DB) {
|
||||
var count int
|
||||
db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if count == 0 {
|
||||
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) {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
|
|
@ -56,58 +81,35 @@ func createTables(db *sql.DB) {
|
|||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (schedule_id) REFERENCES schedules(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
FOREIGN KEY(user_id) REFERENCES users(id),
|
||||
FOREIGN KEY(schedule_id) REFERENCES schedules(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS school_years (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
log.Fatal(err)
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating table: %s\nQuery: %s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
_, err := db.Exec(`
|
||||
INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
1, "admin", string(hash), true, 40.0,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func createIndexes(db *sql.DB) {
|
||||
indexes := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
if _, err := db.Exec(idx); err != nil {
|
||||
log.Printf("Warning: Failed to create index: %v", err)
|
||||
}
|
||||
db.Exec(idx)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package main
|
|||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -726,3 +728,74 @@ func (app *App) DeleteSchoolYearHandler(c echo.Context) error {
|
|||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (app *App) ChangeMyPasswordHandler(c echo.Context) error {
|
||||
claims, err := getClaims(c)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrUnauthorizedMsg())
|
||||
}
|
||||
|
||||
var req ChangePasswordRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("Anfragedaten"))
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 6 {
|
||||
return HandleError(c, ErrInvalidInputMsg("Neues Passwort muss mind. 6 Zeichen lang sein"))
|
||||
}
|
||||
|
||||
var currentHash string
|
||||
err = app.DB.QueryRow("SELECT password FROM users WHERE id = ?", claims.UserID).Scan(¤tHash)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPassword)); err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("Altes Passwort ist falsch"))
|
||||
}
|
||||
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
_, err = app.DB.Exec("UPDATE users SET password = ? WHERE id = ?", string(newHash), claims.UserID)
|
||||
if err != nil {
|
||||
return HandleError(c, ErrDatabaseMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Passwort erfolgreich geändert"})
|
||||
}
|
||||
|
||||
func (app *App) GetLogoHandler(c echo.Context) error {
|
||||
if _, err := os.Stat("school_logo.png"); os.IsNotExist(err) {
|
||||
return c.NoContent(http.StatusNotFound)
|
||||
}
|
||||
c.Response().Header().Set("Cache-Control", "no-cache")
|
||||
return c.File("school_logo.png")
|
||||
}
|
||||
|
||||
func (app *App) UploadLogoHandler(c echo.Context) error {
|
||||
file, err := c.FormFile("logo")
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInvalidInputMsg("Keine Datei hochgeladen"))
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, err := os.Create("school_logo.png")
|
||||
if err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err = io.Copy(dst, src); err != nil {
|
||||
return HandleError(c, ErrInternalMsg(err))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ else
|
|||
fi
|
||||
|
||||
if [ -z "$PORT" ]; then
|
||||
export PORT=8080
|
||||
export PORT=8085
|
||||
fi
|
||||
|
||||
if [ -z "$DB_PATH" ]; then
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -10,6 +14,9 @@ import (
|
|||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var frontendDist embed.FS
|
||||
|
||||
func main() {
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
|
|
@ -26,14 +33,15 @@ func main() {
|
|||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
// CORS Configuration
|
||||
allowOrigins := []string{"*"} // Default for development
|
||||
e.Use(middleware.Gzip())
|
||||
|
||||
e.Use(middleware.Secure())
|
||||
|
||||
allowOrigins := []string{"*"}
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||
if origins != "" {
|
||||
allowOrigins = strings.Split(origins, ",")
|
||||
} else {
|
||||
log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +54,7 @@ func main() {
|
|||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
e.POST("/api/login", app.LoginHandler)
|
||||
e.GET("/api/logo", app.GetLogoHandler)
|
||||
|
||||
protected := e.Group("/api")
|
||||
protected.Use(JWTMiddleware())
|
||||
|
|
@ -59,6 +68,7 @@ func main() {
|
|||
protected.GET("/week-has-entries", app.CheckWeekHasEntries)
|
||||
protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
|
||||
protected.GET("/my-info", app.GetMyInfoHandler)
|
||||
protected.POST("/change-password", app.ChangeMyPasswordHandler)
|
||||
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
|
||||
}
|
||||
|
||||
|
|
@ -83,13 +93,38 @@ func main() {
|
|||
admin.DELETE("/school-years/:id", app.DeleteSchoolYearHandler)
|
||||
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
|
||||
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
|
||||
admin.POST("/settings/logo", app.UploadLogoHandler)
|
||||
}
|
||||
|
||||
e.Static("/", "./static")
|
||||
distDir, err := fs.Sub(frontendDist, "dist")
|
||||
if err != nil {
|
||||
log.Fatal("Fehler beim Laden des eingebetteten Frontends:", err)
|
||||
}
|
||||
|
||||
fileHandler := http.FileServer(http.FS(distDir))
|
||||
e.GET("/*", func(c echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
f, err := distDir.Open(strings.TrimPrefix(path, "/"))
|
||||
if err == nil {
|
||||
f.Close()
|
||||
fileHandler.ServeHTTP(c.Response(), c.Request())
|
||||
return nil
|
||||
}
|
||||
|
||||
index, err := distDir.Open("index.html")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Frontend index.html missing")
|
||||
}
|
||||
defer index.Close()
|
||||
|
||||
stat, _ := index.Stat()
|
||||
http.ServeContent(c.Response(), c.Request(), "index.html", stat.ModTime(), index.(io.ReadSeeker))
|
||||
return nil
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
port = "8085"
|
||||
}
|
||||
|
||||
log.Printf("Server starting on port %s", port)
|
||||
|
|
@ -102,16 +137,9 @@ func customHTTPErrorHandler(err error, c echo.Context) {
|
|||
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
code = he.Code
|
||||
message = he.Message.(string)
|
||||
message = fmt.Sprintf("%v", he.Message)
|
||||
}
|
||||
|
||||
if !c.Response().Committed {
|
||||
if c.Request().Method == http.MethodHead {
|
||||
c.NoContent(code)
|
||||
} else {
|
||||
c.JSON(code, map[string]string{
|
||||
"error": message,
|
||||
})
|
||||
}
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
c.JSON(code, map[string]string{"message": message})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ type WeeklyHours struct {
|
|||
Week int `json:"week"`
|
||||
Year int `json:"year"`
|
||||
TotalHours float64 `json:"total_hours"`
|
||||
YearlyTarget float64 `json:"yearly_target"` // NEU
|
||||
YearlyActual float64 `json:"yearly_actual"` // NEU
|
||||
WeeklyTarget float64 `json:"weekly_target"` // NEU
|
||||
RemainingYearly float64 `json:"remaining_yearly"` // NEU
|
||||
YearlyTarget float64 `json:"yearly_target"`
|
||||
YearlyActual float64 `json:"yearly_actual"`
|
||||
WeeklyTarget float64 `json:"weekly_target"`
|
||||
RemainingYearly float64 `json:"remaining_yearly"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
|
|
@ -101,3 +101,8 @@ type Claims struct {
|
|||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
14
frontend/index.html
Normal 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
33
frontend/jsconfig.json
Normal 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
2061
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "school-timetracker",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.43.8",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"daisyui": "^5.5.14",
|
||||
"tailwindcss": "^4.1.18"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
60
frontend/src/App.svelte
Normal file
60
frontend/src/App.svelte
Normal 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>
|
||||
|
||||
2
frontend/src/app.css
Normal file
2
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
1
frontend/src/assets/svelte.svg
Normal file
1
frontend/src/assets/svelte.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
188
frontend/src/components/AdminDashboard.svelte
Normal file
188
frontend/src/components/AdminDashboard.svelte
Normal 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>
|
||||
102
frontend/src/components/Login.svelte
Normal file
102
frontend/src/components/Login.svelte
Normal 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>
|
||||
42
frontend/src/components/ScheduleItem.svelte
Normal file
42
frontend/src/components/ScheduleItem.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let schedule;
|
||||
export let dayOfWeek;
|
||||
export let isSelected = false;
|
||||
export let isClickable = true;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
$: 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>
|
||||
39
frontend/src/components/ScheduleItems.svelte
Normal file
39
frontend/src/components/ScheduleItems.svelte
Normal 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>
|
||||
34
frontend/src/components/ToastNotification.svelte
Normal file
34
frontend/src/components/ToastNotification.svelte
Normal 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>
|
||||
445
frontend/src/components/UserDashboard.svelte
Normal file
445
frontend/src/components/UserDashboard.svelte
Normal 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>
|
||||
253
frontend/src/components/admin/AdminScheduleTab.svelte
Normal file
253
frontend/src/components/admin/AdminScheduleTab.svelte
Normal 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>
|
||||
86
frontend/src/components/admin/AdminSchoolYearsTab.svelte
Normal file
86
frontend/src/components/admin/AdminSchoolYearsTab.svelte
Normal 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>
|
||||
68
frontend/src/components/admin/AdminSettingsTab.svelte
Normal file
68
frontend/src/components/admin/AdminSettingsTab.svelte
Normal 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>
|
||||
157
frontend/src/components/admin/AdminTimeEntriesTab.svelte
Normal file
157
frontend/src/components/admin/AdminTimeEntriesTab.svelte
Normal 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>
|
||||
120
frontend/src/components/admin/AdminUsersTab.svelte
Normal file
120
frontend/src/components/admin/AdminUsersTab.svelte
Normal 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
221
frontend/src/lib/api.js
Normal 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 });
|
||||
71
frontend/src/lib/stores.js
Normal file
71
frontend/src/lib/stores.js
Normal 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
51
frontend/src/lib/utils.js
Normal 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
9
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
});
|
||||
|
||||
export default app;
|
||||
8
frontend/svelte.config.js
Normal file
8
frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
15
frontend/tailwind.config.js
Normal file
15
frontend/tailwind.config.js
Normal 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
18
frontend/vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue