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:
Patryk Hegenberg 2026-01-15 15:19:53 +01:00
parent 5788d8c767
commit e719f4565f
32 changed files with 4290 additions and 87 deletions

View file

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

View file

@ -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(&currentHash)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPassword)); err != nil {
return HandleError(c, ErrInvalidInputMsg("Altes Passwort ist falsch"))
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
_, err = app.DB.Exec("UPDATE users SET password = ? WHERE id = ?", string(newHash), claims.UserID)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.JSON(http.StatusOK, map[string]string{"message": "Passwort erfolgreich geändert"})
}
func (app *App) GetLogoHandler(c echo.Context) error {
if _, err := os.Stat("school_logo.png"); os.IsNotExist(err) {
return c.NoContent(http.StatusNotFound)
}
c.Response().Header().Set("Cache-Control", "no-cache")
return c.File("school_logo.png")
}
func (app *App) UploadLogoHandler(c echo.Context) error {
file, err := c.FormFile("logo")
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Keine Datei hochgeladen"))
}
src, err := file.Open()
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
defer src.Close()
dst, err := os.Create("school_logo.png")
if err != nil {
return HandleError(c, ErrInternalMsg(err))
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
return HandleError(c, ErrInternalMsg(err))
}
return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"})
}

View file

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

View file

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

View file

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

View file

@ -1,27 +0,0 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/bytes": "1.0.8",
"elm/core": "1.0.5",
"elm/file": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/time": "1.0.0"
},
"indirect": {
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>school-timetracker</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

33
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

2061
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "school-timetracker",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"svelte": "^5.43.8",
"vite": "^7.2.4"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"daisyui": "^5.5.14",
"tailwindcss": "^4.1.18"
}
}

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@ -0,0 +1,60 @@
<script>
import { auth } from './lib/stores';
import Login from './components/Login.svelte';
import UserDashboard from './components/UserDashboard.svelte';
import AdminDashboard from './components/AdminDashboard.svelte';
import ToastNotification from './components/ToastNotification.svelte';
import { onMount } from 'svelte';
import { addToast } from './lib/stores';
$: user = $auth.user;
$: isAuthenticated = $auth.isAuthenticated;
onMount(() => {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = (e) => {
const isDark = e.matches;
const theme = isDark ? 'night' : 'winter';
document.documentElement.setAttribute('data-theme', theme);
};
applyTheme(darkModeMediaQuery);
darkModeMediaQuery.addEventListener('change', applyTheme);
const handleRejection = (event) => {
console.error("Unerwarteter Fehler (Promise):", event.reason);
if (event.reason && event.reason.message !== 'Sitzung abgelaufen') {
addToast("Ein unerwarteter Fehler ist aufgetreten.", "error");
}
};
const handleError = (event) => {
console.error("Kritischer Fehler:", event.error);
addToast("Kritischer Anwendungsfehler. Bitte neu laden.", "error");
};
window.addEventListener('unhandledrejection', handleRejection);
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('unhandledrejection', handleRejection);
window.removeEventListener('error', handleError);
};
});
</script>
<div class="app-container">
<ToastNotification />
<main>
{#if !isAuthenticated}
<Login />
{:else if user?.isAdmin}
<AdminDashboard />
{:else}
<UserDashboard />
{/if}
</main>
</div>

2
frontend/src/app.css Normal file
View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,188 @@
<script>
import { auth } from '../lib/stores';
import { logout } from '../lib/api';
import AdminScheduleTab from './admin/AdminScheduleTab.svelte';
import AdminUsersTab from './admin/AdminUsersTab.svelte';
import AdminTimeEntriesTab from './admin/AdminTimeEntriesTab.svelte';
import AdminSchoolYearsTab from './admin/AdminSchoolYearsTab.svelte';
import AdminSettingsTab from './admin/AdminSettingsTab.svelte';
let activeTab = 'schedule';
const user = $auth.user;
$: pageTitle = getPageTitle(activeTab);
function getPageTitle(tab) {
switch(tab) {
case 'schedule': return 'Stundenplan Konfiguration';
case 'users': return 'Benutzerverwaltung';
case 'timeEntries': return 'Zeiteinträge & Buchungen';
case 'schoolYears': return 'Schuljahre & Perioden';
case 'settings': return 'Einstellungen';
default: return 'Admin';
}
}
let isDrawerOpen = false;
function closeDrawer() { isDrawerOpen = false; }
</script>
<div class="drawer lg:drawer-open">
<input id="admin-drawer" type="checkbox" class="drawer-toggle" bind:checked={isDrawerOpen} />
<div class="drawer-content flex flex-col bg-base-200 min-h-screen">
<div class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8">
<div class="flex-none lg:hidden">
<label for="admin-drawer" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-6 h-6 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
</div>
<div class="flex-1">
<div class="text-sm breadcrumbs hidden sm:block">
<ul>
<li class="opacity-50">Admin</li>
<li class="font-bold text-primary">{pageTitle}</li>
</ul>
</div>
<span class="font-bold text-lg sm:hidden">{pageTitle}</span>
</div>
<div class="flex-none flex items-center gap-4">
<div class="hidden sm:block text-right leading-tight">
<div class="font-bold text-sm">{$auth.user?.username}</div>
<div class="text-xs opacity-50">Administrator</div>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span class="text-xl">{user?.username?.charAt(0).toUpperCase()}</span>
</div>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><button on:click={logout} class="text-error font-bold">Abmelden</button></li>
</ul>
</div>
</div>
</div>
<div class="p-4 md:p-8 lg:p-10 fade-in">
{#if activeTab === 'schedule'}
<AdminScheduleTab />
{:else if activeTab === 'users'}
<AdminUsersTab />
{:else if activeTab === 'timeEntries'}
<AdminTimeEntriesTab />
{:else if activeTab === 'schoolYears'}
<AdminSchoolYearsTab />
{:else if activeTab === 'settings'}
<AdminSettingsTab />
{/if}
</div>
</div>
<div class="drawer-side z-40">
<label for="admin-drawer" class="drawer-overlay"></label>
<aside class="bg-base-100 w-80 h-full flex flex-col border-r border-base-300">
<div class="p-6 border-b border-base-200">
<div class="flex items-center gap-3">
<div class="w-10 h-10 flex items-center justify-center">
<img
src="/api/logo?t={Date.now()}"
alt="Logo"
class="w-full h-full object-contain"
on:error={(e) => {
e.target.style.display='none';
e.target.nextElementSibling.style.display='flex';
}}
/>
<div class="hidden w-10 h-10 rounded bg-primary text-primary-content font-bold text-xl items-center justify-center">
Z
</div>
</div>
<div class="font-bold text-xl tracking-tight">Zeiterfassung</div>
</div>
<div class="text-xs font-mono opacity-50 mt-1 pl-14">Admin Dashboard</div>
</div>
<ul class="menu p-4 w-full gap-2 text-base font-medium flex-1">
<li class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1">Verwaltung</li>
<li>
<button
class="{activeTab === 'schedule' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'schedule'; closeDrawer(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /></svg>
Stundenplan
</button>
</li>
<li>
<button
class="{activeTab === 'users' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'users'; closeDrawer(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /></svg>
Benutzer
</button>
</li>
<li class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1">Daten</li>
<li>
<button
class="{activeTab === 'timeEntries' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'timeEntries'; closeDrawer(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Zeiteinträge
</button>
</li>
<li>
<button
class="{activeTab === 'schoolYears' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'schoolYears'; closeDrawer(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /></svg>
Schuljahre
</button>
</li>
<li class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1">System</li>
<li>
<button
class="{activeTab === 'settings' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'settings'; closeDrawer(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
Einstellungen
</button>
</li>
</ul>
<div class="p-4 border-t border-base-200">
<button on:click={logout} class="btn btn-ghost btn-sm w-full justify-start text-error">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>
Abmelden
</button>
</div>
</aside>
</div>
</div>
<style>
/* Sanfte Fade-In Animation für Tab-Wechsel */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View file

@ -0,0 +1,102 @@
<script>
import { login } from '../lib/api';
import { loading } from '../lib/stores';
let username = '';
let password = '';
let showPassword = false;
let logoSrc = "/api/logo?t=" + Date.now();
async function handleLogin() {
if (!username || !password) return;
await login(username, password);
}
</script>
<div class="hero min-h-screen bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse">
<div class="text-center lg:text-left ml-0 lg:ml-8 mb-4 lg:mb-0">
<img
src={logoSrc}
alt="Schul-Logo"
class="w-32 h-32 mb-6 mx-auto lg:mx-0 object-contain"
on:error={(e) => e.target.style.display='none'}
/>
<h1 class="text-5xl font-bold text-primary">Zeiterfassung</h1>
<p class="py-6 max-w-md">
Willkommen zurück. Bitte melden Sie sich an, um Ihre Arbeitszeiten zu erfassen.
</p>
</div>
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
<div class="card-body">
<div class="form-control">
<label class="label" for="username">
<span class="label-text">Benutzername</span>
</label>
<input
id="username"
type="text"
placeholder="Benutzername"
class="input input-bordered"
bind:value={username}
/>
</div>
<div class="form-control">
<label class="label" for="password">
<span class="label-text">Passwort</span>
</label>
<div class="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
class="input input-bordered w-full pr-10"
bind:value={password}
on:keydown={(e) => e.key === 'Enter' && handleLogin()}
/>
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-base-content/60 hover:text-primary z-10"
on:click={() => showPassword = !showPassword}
tabindex="-1"
>
{#if showPassword}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/if}
</button>
</div>
<label class="label">
<a href="#" class="label-text-alt link link-hover">Passwort vergessen?</a>
</label>
</div>
<div class="form-control mt-6">
<button
class="btn btn-primary"
on:click={handleLogin}
disabled={$loading}
>
{#if $loading}
<span class="loading loading-spinner"></span>
{/if}
Anmelden
</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,42 @@
<script>
import { createEventDispatcher } from 'svelte';
export let schedule;
export let dayOfWeek;
export let isSelected = false;
export let isClickable = true;
const dispatch = createEventDispatcher();
$: bgClass = isSelected
? "bg-success text-success-content shadow-md scale-[1.02]"
: (isClickable ? "bg-base-200 hover:bg-base-300 hover:shadow-sm" : "bg-base-100 opacity-40 grayscale");
$: cursorClass = isClickable ? "cursor-pointer" : "cursor-default";
$: borderClass = isSelected
? "border-l-4 border-l-success-content/20"
: (isClickable ? "border-l-4 border-l-transparent hover:border-l-primary" : "border-l-4 border-l-transparent");
</script>
<div
class="card rounded-lg transition-all duration-200 {bgClass} {cursorClass} {borderClass}"
on:click={() => isClickable && dispatch('toggle')}
on:keydown={() => {}}
role="button"
tabindex="0"
>
<div class="p-3 text-center">
<div class="font-mono font-bold text-sm opacity-90">
{schedule.startTime} - {schedule.endTime}
</div>
<div class="text-sm font-medium mt-1 truncate">
{schedule.title}
</div>
{#if schedule.scheduleType === 'break'}
<div class="mt-1">
<span class="badge badge-xs badge-ghost uppercase tracking-tighter text-[10px]">Pause</span>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,39 @@
<script>
import { createEventDispatcher } from 'svelte';
export let schedule;
export let dayOfWeek;
export let isSelected = false;
export let isClickable = true;
const dispatch = createEventDispatcher();
// Style-Logik basierend auf dem Status
$: boxClass = isSelected
? "box has-background-success-light"
: (isClickable ? "box has-background-white" : "box has-background-light");
$: cursorStyle = isClickable ? "pointer" : "not-allowed";
// Opazität: Wenn nicht klickbar und nicht ausgewählt -> ausgegraut
$: opacity = (isClickable || isSelected) ? "1" : "0.6";
// Rahmen: Wenn klickbar (Hover-Effekt Visualisierung) vs fest
$: borderStyle = (isClickable && !isSelected) ? '2px solid transparent' : '2px solid currentColor';
</script>
<div
class={boxClass}
style="cursor: {cursorStyle}; margin-bottom: 0.5rem; padding: 0.75rem; opacity: {opacity}; transition: all 0.2s ease; border: {borderStyle}"
on:click={() => isClickable && dispatch('toggle')}
on:keydown={() => {}}
role="button"
tabindex="0"
>
<p class="has-text-weight-bold is-size-7">
{schedule.startTime} - {schedule.endTime}
</p>
<p class="is-size-7">
{schedule.title} {schedule.scheduleType === 'break' ? '(Pause)' : ''}
</p>
</div>

View file

@ -0,0 +1,34 @@
<script>
import { toasts, removeToast } from '../lib/stores';
import { fly } from 'svelte/transition';
function getAlertClass(type) {
switch(type) {
case 'error': return 'alert-error';
case 'warning': return 'alert-warning';
case 'success': return 'alert-success';
default: return 'alert-info';
}
}
</script>
<div class="toast toast-top toast-end z-50">
{#each $toasts as toast (toast.id)}
<div
class="alert {getAlertClass(toast.type)} shadow-lg min-w-[300px]"
transition:fly={{ y: -20, duration: 300 }}
>
{#if toast.type === 'error'}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{:else if toast.type === 'success'}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
{/if}
<span>{toast.message}</span>
<button class="btn btn-sm btn-ghost" on:click={() => removeToast(toast.id)}>✕</button>
</div>
{/each}
</div>

View file

@ -0,0 +1,445 @@
<script>
import { onMount } from 'svelte';
import { auth, addToast } from '../lib/stores';
import { logout, getSchedules, getMyTimeEntries, saveTimeEntriesBatch, deleteWeekEntries, changeMyPassword } from '../lib/api';
import { getISOWeek, getISOYear, formatDate, getDateOfISOWeek, calculateHours } from '../lib/utils';
import ScheduleItem from './ScheduleItem.svelte';
const today = new Date();
let currentISOYear = getISOYear(today);
let currentWeek = getISOWeek(today);
let schedules = [];
let allEntries = [];
let existingEntries = [];
let selectedEntries = [];
let weekEditMode = false;
let processing = false;
let isLoadingData = true;
let isDrawerOpen = false;
let showPwModal = false;
let pwData = { old: "", new1: "", new2: "" };
$: hasEntriesForWeek = existingEntries.some(e => e.entryType !== 'manual');
$: weekDates = Array.from({length: 5}, (_, i) => {
const d = getDateOfISOWeek(currentWeek, currentISOYear);
d.setDate(d.getDate() + i);
return {
name: ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'][i],
date: formatDate(d),
dayIndex: i
};
});
$: yearlyTotal = allEntries.reduce((sum, entry) => {
let hours = 0;
if (entry.entryType === 'lesson') hours = 1.0;
else hours = calculateHours(entry.startTime, entry.endTime);
return sum + hours;
}, 0);
$: userTarget = $auth.user?.yearlyWorkHours || 60;
$: remaining = userTarget - yearlyTotal;
$: progressPercent = userTarget > 0 ? Math.min(100, (yearlyTotal / userTarget) * 100) : 0;
$: progressClass = remaining <= 0 ? "progress-success" : (yearlyTotal >= userTarget * 0.8 ? "progress-info" : "progress-warning");
onMount(loadData);
async function handleDeleteWeek() {
if(!confirm("Möchten Sie wirklich alle Einträge dieser Woche löschen?")) return;
processing = true;
try {
await deleteWeekEntries(currentISOYear, currentWeek);
addToast("Woche erfolgreich zurückgesetzt", "success");
weekEditMode = false;
await loadData();
} catch (e) {
console.error(e);
} finally {
processing = false;
}
}
async function loadData() {
isLoadingData = true;
try {
const [schedulesData, entriesData] = await Promise.all([
getSchedules(),
getMyTimeEntries()
]);
schedules = schedulesData;
allEntries = entriesData;
filterEntries(entriesData);
} finally {
isLoadingData = false;
}
}
function filterEntries(entries) {
existingEntries = entries.filter(e => {
const [y, m, d] = e.date.split('-').map(Number);
const entryDate = new Date(y, m - 1, d);
return getISOYear(entryDate) === currentISOYear && getISOWeek(entryDate) === currentWeek;
});
selectedEntries = existingEntries.map(e => {
const sched = schedules.find(s => s.id === e.scheduleId);
if (sched) return { scheduleId: e.scheduleId, dayOfWeek: sched.dayOfWeek };
return null;
}).filter(item => item !== null);
}
function toggleSelection(scheduleId, dayOfWeek) {
const index = selectedEntries.findIndex(e => e.scheduleId === scheduleId && e.dayOfWeek === dayOfWeek);
if (index >= 0) selectedEntries.splice(index, 1);
else selectedEntries.push({ scheduleId, dayOfWeek });
selectedEntries = selectedEntries;
}
async function saveEntries() {
processing = true;
try {
const entriesToSave = selectedEntries.map(sel => {
const sched = schedules.find(s => s.id === sel.scheduleId);
const dateObj = weekDates.find(d => d.dayIndex === sel.dayOfWeek);
return {
schedule_id: sel.scheduleId,
date: dateObj.date,
type: sched.scheduleType,
start_time: sched.startTime,
end_time: sched.endTime
};
});
if(entriesToSave.length > 0) await saveTimeEntriesBatch(entriesToSave);
weekEditMode = false;
await loadData();
} finally {
processing = false;
}
}
function changeWeek(delta) {
const d = getDateOfISOWeek(currentWeek, currentISOYear);
d.setDate(d.getDate() + (delta * 7));
currentWeek = getISOWeek(d);
currentISOYear = getISOYear(d);
loadData();
}
function closeDrawer() { isDrawerOpen = false; }
async function handleChangePassword() {
if (pwData.new1 !== pwData.new2) {
addToast("Die neuen Passwörter stimmen nicht überein", "warning");
return;
}
if (pwData.new1.length < 6) {
addToast("Passwort zu kurz (min. 6 Zeichen)", "warning");
return;
}
try {
await changeMyPassword(pwData.old, pwData.new1);
addToast("Passwort erfolgreich geändert!", "success");
showPwModal = false;
pwData = { old: "", new1: "", new2: "" };
} catch (e) {
}
}
</script>
<div class="drawer lg:drawer-open">
<input id="user-drawer" type="checkbox" class="drawer-toggle" bind:checked={isDrawerOpen} />
<div class="drawer-content flex flex-col bg-base-200 min-h-screen pb-20">
<div class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8">
<div class="flex-none lg:hidden">
<label for="user-drawer" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-6 h-6 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>
</label>
</div>
<div class="flex-1">
<div class="text-sm breadcrumbs hidden sm:block">
<ul>
<li class="opacity-50">Mein Bereich</li>
<li class="font-bold text-primary">Stundenplan</li>
</ul>
</div>
<span class="font-bold text-lg sm:hidden">KW {currentWeek}</span>
</div>
<div class="flex-none flex items-center gap-4">
<div class="hidden sm:block text-right leading-tight">
<div class="font-bold text-sm">{$auth.user?.username}</div>
<div class="text-xs opacity-50">Benutzer</div>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-xl">{$auth.user?.username?.charAt(0).toUpperCase()}</span>
</div>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><button on:click={logout} class="text-error font-bold">Abmelden</button></li>
</ul>
</div>
</div>
</div>
<div class="p-4 md:p-8 lg:p-10 fade-in space-y-6">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-2 sm:p-4 flex-row items-center justify-between">
<button class="btn btn-circle btn-ghost" on:click={() => changeWeek(-1)} disabled={isLoadingData}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>
</button>
<div class="text-center">
<p class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1">Kalenderwoche</p>
<h2 class="text-xl sm:text-3xl font-bold">KW {currentWeek} <span class="text-base-content/30">/</span> {currentISOYear}</h2>
<p class="text-xs sm:text-sm opacity-60 mt-1 font-mono bg-base-200 inline-block px-2 py-1 rounded">{weekDates[0]?.date}{weekDates[4]?.date}</p>
</div>
<button class="btn btn-circle btn-ghost" on:click={() => changeWeek(1)} disabled={isLoadingData}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /></svg>
</button>
</div>
</div>
<div class="lg:hidden grid grid-cols-2 gap-2">
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Geleistet</div>
<div class="stat-value text-lg">{yearlyTotal.toFixed(1)}</div>
</div>
</div>
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Offen</div>
<div class="stat-value text-lg {remaining <= 0 ? 'text-success' : 'text-warning'}">{Math.max(0, remaining).toFixed(1)}</div>
</div>
</div>
</div>
{#if isLoadingData}
<div class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200">
<div class="flex gap-4">
{#each Array(5) as _}
<div class="flex-1 space-y-4">
<div class="skeleton h-8 w-full mb-4"></div>
<div class="skeleton h-20 w-full rounded"></div>
<div class="skeleton h-20 w-full rounded"></div>
</div>
{/each}
</div>
</div>
<div class="lg:hidden space-y-4">
{#each Array(5) as _} <div class="skeleton h-16 w-full rounded-lg"></div> {/each}
</div>
{:else}
{#if hasEntriesForWeek && !weekEditMode}
<div role="alert" class="alert alert-success shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div><h3 class="font-bold">Erfasst!</h3><div class="text-xs">Stunden gespeichert.</div></div>
<button class="btn btn-sm btn-outline" on:click={() => weekEditMode = true} disabled={processing}>Bearbeiten</button>
</div>
{:else if weekEditMode}
<div role="alert" class="alert alert-warning shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<div><h3 class="font-bold">Bearbeitungsmodus</h3><div class="text-xs">Speichern nicht vergessen!</div></div>
<div class="flex gap-2">
<button class="btn btn-sm btn-error" on:click={handleDeleteWeek} disabled={processing}>Löschen</button>
<button class="btn btn-sm btn-ghost" on:click={() => weekEditMode = false}>Abbrechen</button>
</div>
</div>
{:else}
<div role="alert" class="alert alert-info shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div><h3 class="font-bold">Zeiterfassung</h3><div class="text-xs">Wählen Sie Ihre Stunden.</div></div>
</div>
{/if}
<div class="overflow-x-auto hidden lg:block bg-base-100 rounded-2xl shadow-xl border border-base-200">
<table class="table table-fixed w-full">
<thead>
<tr>
{#each weekDates as day}
<th class="text-center bg-base-200/50 py-4">
<div class="font-bold text-lg">{day.name}</div>
<div class="text-xs font-normal opacity-50">{day.date}</div>
</th>
{/each}
</tr>
</thead>
<tbody>
<tr>
{#each weekDates as day}
<td class="align-top p-2 min-w-[160px] border-r border-base-200 last:border-0">
<div class="space-y-2">
{#each schedules.filter(s => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem
{schedule} dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(e => e.scheduleId === schedule.id && e.dayOfWeek === day.dayIndex)}
isClickable={(!hasEntriesForWeek || weekEditMode) && !processing}
on:toggle={() => toggleSelection(schedule.id, day.dayIndex)}
/>
{/each}
</div>
</td>
{/each}
</tr>
</tbody>
</table>
</div>
<div class="lg:hidden space-y-4">
{#each weekDates as day}
<div class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200">
<input type="checkbox" />
<div class="collapse-title text-lg font-medium flex justify-between items-center">
<span>{day.name}</span>
<span class="text-sm opacity-50 font-mono">{day.date}</span>
</div>
<div class="collapse-content">
<div class="pt-2 space-y-2">
{#each schedules.filter(s => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem
{schedule} dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(e => e.scheduleId === schedule.id && e.dayOfWeek === day.dayIndex)}
isClickable={(!hasEntriesForWeek || weekEditMode) && !processing}
on:toggle={() => toggleSelection(schedule.id, day.dayIndex)}
/>
{/each}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
<div class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none">
<button
class="btn btn-primary btn-lg w-full shadow-2xl border-primary-focus transform active:scale-[0.99] transition-transform pointer-events-auto"
disabled={selectedEntries.length === 0 || processing}
on:click={saveEntries}
>
{#if processing}<span class="loading loading-spinner"></span>{/if}
{weekEditMode ? 'Änderungen speichern' : 'Speichern'}
</button>
</div>
{/if}
</div>
<div class="drawer-side z-40">
<label for="user-drawer" class="drawer-overlay"></label>
<aside class="bg-base-100 w-80 h-full flex flex-col border-r border-base-300">
<div class="p-6 border-b border-base-200">
<div class="flex items-center gap-3">
<div class="w-10 h-10 flex items-center justify-center">
<img
src="/api/logo?t={Date.now()}"
alt="Logo"
class="w-full h-full object-contain"
on:error={(e) => {
e.target.style.display='none';
e.target.nextElementSibling.style.display='flex';
}}
/>
<div class="hidden w-10 h-10 rounded bg-primary text-primary-content font-bold text-xl items-center justify-center">
Z
</div>
</div>
<div class="font-bold text-xl tracking-tight">Zeiterfassung</div>
</div>
<div class="text-xs font-mono opacity-50 mt-1 pl-14">User Dashboard</div>
</div>
<ul class="menu p-4 w-full gap-2 text-base font-medium">
<li class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1">Navigation</li>
<li>
<a class="active bg-primary/10 text-primary" href="#" on:click={() => closeDrawer()}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /></svg>
Stundenplan
</a>
</li>
<li>
<button on:click={() => showPwModal = true}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></svg>
Passwort ändern
</button>
</li>
</ul>
<div class="mt-auto p-4 bg-base-200 m-4 rounded-xl space-y-4">
<div class="text-xs font-bold uppercase opacity-50 tracking-wider">Jahresfortschritt</div>
<div class="flex justify-between items-end">
<div>
<div class="text-3xl font-bold text-primary">{yearlyTotal.toFixed(1)}</div>
<div class="text-xs opacity-70">von {userTarget.toFixed(1)} Stunden</div>
</div>
<div class="radial-progress text-primary text-xs font-bold" style="--value:{progressPercent}; --size:3rem;">
{Math.round(progressPercent)}%
</div>
</div>
<div class="divider my-1"></div>
<div class="flex justify-between text-sm">
<span class="opacity-70">Verbleibend:</span>
<span class="font-bold {remaining <= 0 ? 'text-success' : 'text-warning'}">
{Math.max(0, remaining).toFixed(1)} h
</span>
</div>
<progress class="progress w-full h-2 {progressClass}" value={progressPercent} max="100"></progress>
</div>
<div class="p-4 border-t border-base-200">
<button on:click={logout} class="btn btn-ghost btn-sm w-full justify-start text-error">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>
Abmelden
</button>
</div>
</aside>
</div>
</div>
<dialog class="modal {showPwModal ? 'modal-open' : ''}">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Passwort ändern</h3>
<div class="space-y-4">
<div class="form-control">
<label class="label">Altes Passwort</label>
<input type="password" class="input input-bordered" bind:value={pwData.old} />
</div>
<div class="form-control">
<label class="label">Neues Passwort</label>
<input type="password" class="input input-bordered" bind:value={pwData.new1} />
</div>
<div class="form-control">
<label class="label">Wiederholung</label>
<input type="password" class="input input-bordered" bind:value={pwData.new2} />
</div>
</div>
<div class="modal-action">
<button class="btn" on:click={() => showPwModal = false}>Abbrechen</button>
<button class="btn btn-primary" on:click={handleChangePassword} disabled={!pwData.old || !pwData.new1}>Speichern</button>
</div>
</div>
</dialog>
<style>
.fade-in { animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
</style>

View file

@ -0,0 +1,253 @@
<script>
import { onMount } from 'svelte';
import { getSchedules, createSchedule, deleteSchedule } from '../../lib/api';
import { loading, addToast } from '../../lib/stores';
let schedules = [];
let fileInput;
let newSchedule = {
dayOfWeek: "",
startTime: "",
endTime: "",
scheduleType: "lesson",
title: ""
};
const dayNames = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];
let showCopyModal = false;
let copySourceDay = "";
let copyTargetDays = [];
let deleteExisting = true;
let copyProcessing = false;
let importProcessing = false;
onMount(loadSchedules);
async function loadSchedules() {
schedules = await getSchedules();
}
function isValidTimeRange(start, end) {
if (!start || !end) return false;
const [h1, m1] = start.split(':').map(Number);
const [h2, m2] = end.split(':').map(Number);
return (h2 * 60 + m2) > (h1 * 60 + m1);
}
async function handleCreate() {
if (newSchedule.dayOfWeek === "" || !newSchedule.startTime || !newSchedule.endTime) {
addToast("Bitte alle Felder ausfüllen", "warning");
return;
}
if (!isValidTimeRange(newSchedule.startTime, newSchedule.endTime)) {
addToast("Endzeit muss nach Startzeit liegen", "error");
return;
}
try {
await createSchedule(newSchedule);
newSchedule = { dayOfWeek: "", startTime: "", endTime: "", scheduleType: "lesson", title: "" };
await loadSchedules();
addToast("Eintrag erstellt", "success");
} catch (e) {}
}
async function handleDelete(id) {
if(confirm('Wirklich löschen?')) {
await deleteSchedule(id);
await loadSchedules();
addToast("Gelöscht", "success");
}
}
function handleExport() {
if (schedules.length === 0) return addToast("Keine Daten", "warning");
const exportData = schedules.map(s => ({ ...s, id: undefined }));
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
const a = document.createElement('a'); a.href = dataStr; a.download = "stundenplan_export.json"; a.click();
}
function triggerImport() { fileInput.click(); }
async function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
event.target.value = '';
if (!confirm("Import starten? Duplikate möglich.")) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const importedData = JSON.parse(e.target.result);
importProcessing = true;
addToast(`Importiere ${importedData.length}...`, "info");
let count = 0; let errors = 0;
for (const item of importedData) {
if (item.dayOfWeek !== undefined && isValidTimeRange(item.startTime, item.endTime)) {
await createSchedule({
dayOfWeek: String(item.dayOfWeek),
startTime: item.startTime,
endTime: item.endTime,
scheduleType: item.scheduleType || 'lesson',
title: item.title || ''
});
count++;
} else errors++;
}
await loadSchedules();
errors > 0 ? addToast(`${count} importiert, ${errors} ungültig`, "warning") : addToast("Import erfolgreich", "success");
} catch (err) { addToast(err.message, "error"); }
finally { importProcessing = false; }
};
reader.readAsText(file);
}
function toggleTargetDay(dayIndex) {
const sIndex = String(dayIndex);
copyTargetDays = copyTargetDays.includes(sIndex) ? copyTargetDays.filter(d => d !== sIndex) : [...copyTargetDays, sIndex];
}
async function handleCopyDay() {
if (copySourceDay === "" || copyTargetDays.length === 0) return;
copyProcessing = true;
try {
const sourceEntries = schedules.filter(s => String(s.dayOfWeek) === copySourceDay);
if (sourceEntries.length === 0) throw new Error("Keine Quelleinträge");
if (deleteExisting) {
const toDel = schedules.filter(s => copyTargetDays.includes(String(s.dayOfWeek)));
for (const s of toDel) await deleteSchedule(s.id);
}
for (const targetDay of copyTargetDays) {
for (const entry of sourceEntries) {
if(isValidTimeRange(entry.startTime, entry.endTime)) {
await createSchedule({ ...entry, dayOfWeek: targetDay, id: undefined });
}
}
}
addToast("Kopieren erfolgreich", "success");
showCopyModal = false; copyTargetDays = []; copySourceDay = ""; await loadSchedules();
} catch (e) { addToast(e.message, "error"); }
finally { copyProcessing = false; }
}
</script>
<div class="flex justify-between items-center mb-6">
<div class="join">
<input type="file" accept=".json" class="hidden" bind:this={fileInput} on:change={handleFileSelect} />
<button class="btn btn-sm join-item" on:click={handleExport} disabled={schedules.length === 0}><i class="fas fa-download mr-2"></i> Export</button>
<button class="btn btn-sm join-item" on:click={triggerImport} disabled={importProcessing}><i class="fas fa-upload mr-2"></i> Import</button>
<button class="btn btn-sm btn-info join-item" on:click={() => showCopyModal = true}><i class="fas fa-copy mr-2"></i> Tag kopieren</button>
</div>
</div>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<h3 class="card-title text-sm opacity-60 uppercase mb-2">Neuen Eintrag erstellen</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Wochentag</span></label>
<select class="select select-bordered w-full" bind:value={newSchedule.dayOfWeek}>
<option value="">-- Wählen --</option>
{#each dayNames as day, i} <option value={String(i)}>{day}</option> {/each}
</select>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Startzeit</span></label>
<input type="time" class="input input-bordered w-full" bind:value={newSchedule.startTime} />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Endzeit</span></label>
<input type="time" class="input input-bordered w-full" bind:value={newSchedule.endTime} />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Typ</span></label>
<select class="select select-bordered w-full" bind:value={newSchedule.scheduleType}>
<option value="lesson">Unterricht</option>
<option value="break">Pause</option>
</select>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label"><span class="label-text font-bold">Titel / Fach</span></label>
<input type="text" class="input input-bordered w-full" placeholder="z.B. Mathematik" bind:value={newSchedule.title} />
</div>
</div>
<div class="card-actions justify-end mt-6">
<button class="btn btn-primary px-8" on:click={handleCreate} disabled={!newSchedule.dayOfWeek || $loading}>
{#if $loading}<span class="loading loading-spinner"></span>{/if}
Hinzufügen
</button>
</div>
</div>
</div>
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-xl border border-base-200">
<table class="table table-zebra w-full">
<thead><tr><th>Tag</th><th>Zeit</th><th>Typ</th><th>Titel</th><th>Aktion</th></tr></thead>
<tbody>
{#each schedules as s (s.id)}
<tr>
<td class="font-bold">{dayNames[s.dayOfWeek]}</td>
<td>{s.startTime} - {s.endTime}</td>
<td><span class="badge {s.scheduleType === 'break' ? 'badge-ghost' : 'badge-primary'}">{s.scheduleType === 'break' ? 'Pause' : 'Unterricht'}</span></td>
<td>{s.title}</td>
<td><button class="btn btn-xs btn-error btn-outline" on:click={() => handleDelete(s.id)}>Löschen</button></td>
</tr>
{/each}
</tbody>
</table>
</div>
<dialog class="modal {showCopyModal ? 'modal-open' : ''}">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Tag kopieren</h3>
<div class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text">Quelle</span></label>
<select class="select select-bordered w-full" bind:value={copySourceDay}>
<option value="">Wählen...</option>
{#each dayNames as day, i} <option value={String(i)}>{day}</option> {/each}
</select>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text">Ziel-Tage</span></label>
<div class="flex flex-wrap gap-2">
{#each dayNames as day, i}
{#if String(i) !== copySourceDay}
<label class="cursor-pointer label border rounded-lg px-3 py-2 hover:bg-base-200 transition-colors">
<input type="checkbox" class="checkbox checkbox-sm checkbox-primary mr-3"
checked={copyTargetDays.includes(String(i))}
on:change={() => toggleTargetDay(i)} />
<span class="label-text font-medium">{day}</span>
</label>
{/if}
{/each}
</div>
</div>
<div class="form-control bg-base-200 p-3 rounded-lg mt-4">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" class="toggle toggle-error" bind:checked={deleteExisting} />
<span class="label-text">Vorhandene Einträge am Ziel vorher löschen</span>
</label>
</div>
</div>
<div class="modal-action">
<button class="btn" on:click={() => showCopyModal = false} disabled={copyProcessing}>Abbrechen</button>
<button class="btn btn-success" on:click={handleCopyDay} disabled={!copySourceDay || copyTargetDays.length === 0 || copyProcessing}>
{copyProcessing ? 'Kopiere...' : 'Kopieren starten'}
</button>
</div>
</div>
</dialog>

View file

@ -0,0 +1,86 @@
<script>
import { onMount } from 'svelte';
import { getSchoolYears, getActiveSchoolYear, createSchoolYear, activateSchoolYear, deleteSchoolYear } from '../../lib/api';
import { loading } from '../../lib/stores';
let schoolYears = [];
let activeSchoolYear = null;
let newYear = { name: "", startDate: "", endDate: "" };
onMount(loadData);
async function loadData() {
const [years, active] = await Promise.all([ getSchoolYears(), getActiveSchoolYear().catch(() => null) ]);
schoolYears = years;
activeSchoolYear = active;
}
async function handleCreate() {
await createSchoolYear(newYear);
newYear = { name: "", startDate: "", endDate: "" };
await loadData();
}
async function handleActivate(id) { await activateSchoolYear(id); await loadData(); }
async function handleDelete(id) { if(confirm('Löschen?')) { await deleteSchoolYear(id); await loadData(); } }
</script>
{#if activeSchoolYear}
<div class="alert alert-info shadow-lg mb-6">
<i class="fas fa-calendar-check"></i>
<div>
<h3 class="font-bold">Aktives Schuljahr: {activeSchoolYear.name}</h3>
<div class="text-xs">{activeSchoolYear.startDate} bis {activeSchoolYear.endDate}</div>
</div>
</div>
{:else}
<div class="alert alert-warning shadow-lg mb-6">
<i class="fas fa-exclamation-triangle"></i>
<span>Kein Schuljahr aktiv! Bitte eines aktivieren.</span>
</div>
{/if}
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div class="form-control w-full">
<label class="label">Name</label>
<input type="text" class="input input-bordered" placeholder="2024/2025" bind:value={newYear.name} />
</div>
<div class="form-control w-full">
<label class="label">Start</label>
<input type="date" class="input input-bordered" bind:value={newYear.startDate} />
</div>
<div class="form-control w-full">
<label class="label">Ende</label>
<input type="date" class="input input-bordered" bind:value={newYear.endDate} />
</div>
<button class="btn btn-primary" on:click={handleCreate} disabled={$loading}>Erstellen</button>
</div>
</div>
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-xl">
<table class="table table-zebra w-full">
<thead><tr><th>Name</th><th>Start</th><th>Ende</th><th>Status</th><th>Aktion</th></tr></thead>
<tbody>
{#each schoolYears as sy}
<tr>
<td class="font-bold">{sy.name}</td>
<td>{sy.startDate}</td>
<td>{sy.endDate}</td>
<td>
{#if sy.isActive}
<span class="badge badge-success">Aktiv</span>
{:else}
<span class="badge badge-ghost">Inaktiv</span>
{/if}
</td>
<td>
{#if !sy.isActive}
<button class="btn btn-xs btn-info" on:click={() => handleActivate(sy.id)}>Aktivieren</button>
{/if}
<button class="btn btn-xs btn-error btn-outline ml-2" on:click={() => handleDelete(sy.id)}>Löschen</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>

View file

@ -0,0 +1,68 @@
<script>
import { uploadLogo } from '../../lib/api';
import { addToast } from '../../lib/stores';
let fileInput;
let previewSrc = "/api/logo?t=" + Date.now();
let uploading = false;
async function handleFileChange(e) {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
addToast("Bitte nur Bilder hochladen", "warning");
return;
}
uploading = true;
try {
await uploadLogo(file);
addToast("Logo aktualisiert", "success");
const timestamp = Date.now();
previewSrc = `/api/logo?t=${timestamp}`;
window.dispatchEvent(new Event('logo-updated'));
} catch (err) {
addToast(err.message, "error");
} finally {
uploading = false;
}
}
</script>
<div class="card bg-base-100 shadow-xl border border-base-200 max-w-2xl">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Schuleinstellungen</h3>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-bold">Schul-Logo</span>
<span class="label-text-alt">Wird im Login und Dashboard angezeigt</span>
</label>
<div class="flex items-center gap-6 mt-2">
<div class="avatar placeholder border border-base-300 rounded-lg p-1 bg-base-200">
<div class="w-24 h-24 rounded-lg">
<img src={previewSrc} alt="Logo" on:error={(e) => e.target.style.display='none'} />
</div>
</div>
<div>
<input
type="file"
accept="image/png, image/jpeg"
class="file-input file-input-bordered file-input-primary w-full max-w-xs"
bind:this={fileInput}
on:change={handleFileChange}
disabled={uploading}
/>
<div class="text-xs text-base-content/50 mt-2">
Empfohlen: PNG mit transparentem Hintergrund.<br>
Max. 2MB.
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,157 @@
<script>
import { onMount } from 'svelte';
import { getAllTimeEntries, getUsers, createAdminTimeEntry, updateTimeEntry, deleteTimeEntry, getYearlySummary, downloadYearlySummaryPDF } from '../../lib/api';
import { calculateHours } from '../../lib/utils';
import { loading, addToast } from '../../lib/stores';
let timeEntries = [];
let users = [];
let yearlySummary = [];
let manualEntry = { selectedUserId: "", date: "", hours: "", type: "manual" };
let editingEntryId = null;
let editForm = {};
onMount(async () => {
await Promise.all([loadEntries(), loadUsers(), loadSummary()]);
});
async function loadEntries() { timeEntries = await getAllTimeEntries(); }
async function loadUsers() { users = await getUsers(); }
async function loadSummary() { yearlySummary = await getYearlySummary(); }
async function handleManualEntry() {
if(!manualEntry.selectedUserId || !manualEntry.date || !manualEntry.hours) {
addToast("Bitte alle Felder ausfüllen", "warning"); return;
}
await createAdminTimeEntry({ ...manualEntry, hours: parseFloat(manualEntry.hours) });
manualEntry.hours = "";
await loadEntries(); await loadSummary();
addToast("Gebucht", "success");
}
async function handlePdfDownload() {
try {
const blob = await downloadYearlySummaryPDF();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `Jahresuebersicht-${new Date().getFullYear()}.pdf`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
} catch (e) { addToast("PDF Fehler", "error"); }
}
function startEdit(entry) { editingEntryId = entry.id; editForm = { ...entry }; }
async function saveEdit() { await updateTimeEntry(editingEntryId, editForm); editingEntryId = null; await loadEntries(); loadSummary(); }
async function deleteEntry(id) { if(confirm('Löschen?')) { await deleteTimeEntry(id); await loadEntries(); loadSummary(); } }
</script>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
<button class="btn btn-primary w-full sm:w-auto" on:click={handlePdfDownload} disabled={$loading}>
<i class="fas fa-file-pdf mr-2"></i> PDF Bericht
</button>
</div>
<div class="card bg-base-100 shadow-xl mb-8 border border-base-200">
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-sm opacity-60 uppercase mb-4">Jahresübersicht</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full whitespace-nowrap">
<thead><tr><th>Mitarbeiter</th><th class="text-right">Soll</th><th class="text-right">Ist</th><th class="text-right">Differenz</th><th>Status</th></tr></thead>
<tbody>
{#each yearlySummary as s}
<tr>
<td class="font-bold">{s.username}</td>
<td class="text-right">{s.yearlyTarget}</td>
<td class="text-right">{s.yearlyActual}</td>
<td class="text-right font-mono {s.remainingYearly > 0 ? 'text-warning' : 'text-success'}">{s.remainingYearly.toFixed(1)}</td>
<td>
{#if s.remainingYearly > 0}
<span class="badge badge-warning badge-sm">Offen</span>
{:else}
<span class="badge badge-success badge-sm">Erfüllt</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-lg mb-8">
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-sm uppercase opacity-70">Manuelle Korrektur / Eintragung</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end">
<div class="form-control">
<label class="label"><span class="label-text">Mitarbeiter</span></label>
<select class="select select-bordered w-full" bind:value={manualEntry.selectedUserId}>
<option value="">Wählen...</option>
{#each users as u} <option value={u.id}>{u.username}</option> {/each}
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Datum</span></label>
<input type="date" class="input input-bordered w-full" bind:value={manualEntry.date}>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Stunden (+/-)</span></label>
<input type="number" step="0.5" class="input input-bordered w-full" bind:value={manualEntry.hours} placeholder="-1.5 od. 2.0">
</div>
<button class="btn btn-info w-full" on:click={handleManualEntry} disabled={!manualEntry.selectedUserId || $loading}>
Buchen
</button>
</div>
</div>
</div>
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-xl border border-base-200">
<table class="table table-zebra w-full whitespace-nowrap">
<thead><tr><th>User</th><th>Datum</th><th>Zeit</th><th>Typ</th><th>Stunden</th><th>Aktion</th></tr></thead>
<tbody>
{#each timeEntries as entry (entry.id)}
{#if editingEntryId === entry.id}
<tr class="bg-base-200">
<td>{entry.username}</td>
<td><input type="date" class="input input-xs input-bordered w-24" bind:value={editForm.date}></td>
<td>
<div class="flex flex-col gap-1">
<input type="time" class="input input-xs input-bordered" bind:value={editForm.startTime}>
<input type="time" class="input input-xs input-bordered" bind:value={editForm.endTime}>
</div>
</td>
<td>
<select class="select select-bordered select-xs w-20" bind:value={editForm.entryType}>
<option value="lesson">Unt.</option>
<option value="break">Pause</option>
<option value="manual">Man.</option>
</select>
</td>
<td>-</td>
<td>
<div class="join">
<button class="btn btn-xs btn-success join-item" on:click={saveEdit}><i class="fas fa-check"></i></button>
<button class="btn btn-xs btn-ghost join-item" on:click={() => editingEntryId = null}><i class="fas fa-times"></i></button>
</div>
</td>
</tr>
{:else}
<tr>
<td class="font-bold">{entry.username}</td>
<td>{entry.date}</td>
<td>{entry.startTime} - {entry.endTime}</td>
<td>
<span class="badge badge-sm {entry.entryType==='manual'?'badge-info':'badge-ghost'}">
{entry.entryType}
</span>
</td>
<td class="font-mono font-bold">{entry.entryType === 'lesson' ? '1.0' : calculateHours(entry.startTime, entry.endTime)}</td>
<td>
<div class="join">
<button class="btn btn-xs btn-ghost join-item" on:click={() => startEdit(entry)}>Edit</button>
<button class="btn btn-xs btn-ghost text-error join-item" on:click={() => deleteEntry(entry.id)}>Del</button>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>

View file

@ -0,0 +1,120 @@
<script>
import { onMount } from 'svelte';
import { getUsers, createUser, deleteUser, updateUserWorkHours, resetUserPassword } from '../../lib/api';
import { loading, addToast } from '../../lib/stores';
let users = [];
let newUser = { username: "", password: "", isAdmin: false };
let editingUserId = null;
let editingWorkHours = "";
let resetPasswordUserId = null;
let resetPasswordNew = "";
onMount(async () => users = await getUsers());
async function handleCreate() {
if(!newUser.username || !newUser.password) {
addToast("Benutzername und Passwort pflicht", "warning"); return;
}
await createUser(newUser);
newUser = { username: "", password: "", isAdmin: false };
users = await getUsers();
addToast("User angelegt", "success");
}
async function handleDelete(id) {
if(confirm('Löschen?')) {
await deleteUser(id); users = await getUsers(); addToast("Gelöscht", "success");
}
}
function startEditHours(user) {
editingUserId = user.id; editingWorkHours = String(user.yearlyWorkHours); resetPasswordUserId = null;
}
async function saveWorkHours() {
if (editingUserId) {
await updateUserWorkHours(editingUserId, editingWorkHours);
editingUserId = null; users = await getUsers(); addToast("Gespeichert", "success");
}
}
function startResetPassword(user) {
resetPasswordUserId = user.id; resetPasswordNew = ""; editingUserId = null;
}
async function savePassword() {
if (resetPasswordUserId && resetPasswordNew) {
await resetUserPassword(resetPasswordUserId, resetPasswordNew);
resetPasswordUserId = null; addToast("Passwort geändert", "success");
}
}
</script>
<div class="card bg-base-100 shadow-xl mb-8 border border-base-200">
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-sm opacity-60 uppercase mb-2">Neuen Benutzer anlegen</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end">
<div class="form-control w-full">
<label class="label"><span class="label-text">Benutzername</span></label>
<input type="text" class="input input-bordered w-full" bind:value={newUser.username} />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text">Passwort</span></label>
<input type="password" class="input input-bordered w-full" bind:value={newUser.password} />
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4 h-full items-center">
<span class="label-text">Admin-Rechte</span>
<input type="checkbox" class="checkbox" bind:checked={newUser.isAdmin} />
</label>
</div>
<button class="btn btn-primary w-full" on:click={handleCreate} disabled={$loading}>Anlegen</button>
</div>
</div>
</div>
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-xl border border-base-200">
<table class="table table-zebra w-full whitespace-nowrap">
<thead>
<tr><th>ID</th><th>Name</th><th>Rolle</th><th>Jahresstunden</th><th>Aktionen</th></tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr>
<td class="opacity-50 text-xs">{user.id}</td>
<td class="font-bold">{user.username}</td>
<td>
{#if user.isAdmin}<span class="badge badge-error badge-sm">Admin</span>
{:else}<span class="badge badge-ghost badge-sm">User</span>{/if}
</td>
<td>
{#if editingUserId === user.id}
<div class="join">
<input class="input input-sm input-bordered join-item w-16" type="number" step="0.5" bind:value={editingWorkHours} />
<button class="btn btn-sm btn-success join-item" on:click={saveWorkHours}>✓</button>
<button class="btn btn-sm btn-ghost join-item" on:click={() => editingUserId = null}>✕</button>
</div>
{:else}
{user.yearlyWorkHours} h
{/if}
</td>
<td>
{#if resetPasswordUserId === user.id}
<div class="join">
<input class="input input-sm input-bordered join-item w-24" type="password" placeholder="Neues PW" bind:value={resetPasswordNew} />
<button class="btn btn-sm btn-success join-item" on:click={savePassword}>OK</button>
<button class="btn btn-sm btn-ghost join-item" on:click={() => resetPasswordUserId = null}>✕</button>
</div>
{:else if user.id !== 1} <div class="join">
<button class="btn btn-xs btn-outline join-item" on:click={() => startEditHours(user)}>Std.</button>
<button class="btn btn-xs btn-warning join-item" on:click={() => startResetPassword(user)}>PW</button>
<button class="btn btn-xs btn-error join-item" on:click={() => handleDelete(user.id)}>Del</button>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>

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

@ -0,0 +1,221 @@
import { get } from 'svelte/store';
import { auth, addToast, loading } from './stores';
const BASE_URL = '/api';
function parseJwt(token) {
if (!token) return {};
try {
const base64Url = token.split('.')[1];
if (!base64Url) return {};
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const padding = base64.length % 4;
if (padding) base64 += '='.repeat(4 - padding);
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''));
return JSON.parse(jsonPayload);
} catch (e) {
console.error("JWT Parse Error:", e);
return {};
}
}
function mapScheduleFromApi(s) {
return {
id: s.id, dayOfWeek: s.day_of_week, startTime: s.start_time,
endTime: s.end_time, scheduleType: s.type, title: s.title
};
}
function mapScheduleToApi(s) {
return {
day_of_week: parseInt(s.dayOfWeek), start_time: s.startTime,
end_time: s.endTime, type: s.scheduleType, title: s.title
};
}
function mapTimeEntryFromApi(e) {
return {
id: e.id, userId: e.user_id, scheduleId: e.schedule_id, date: e.date,
entryType: e.type || e.entry_type, username: e.username,
startTime: e.start_time, endTime: e.end_time
};
}
function mapUserFromApi(u) {
return {
...u,
yearlyWorkHours: u.yearly_hours || u.yearly_work_hours || u.yearlyWorkHours || 0,
isAdmin: !!(u.isAdmin || u.is_admin)
};
}
async function request(endpoint, method = 'GET', body = null, isBlob = false) {
loading.set(true);
const token = get(auth).token;
const headers = {};
if (!isBlob) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const res = await fetch(`${BASE_URL}${endpoint}`, {
method, headers, body: body ? JSON.stringify(body) : null
});
loading.set(false);
if (!res.ok) {
if (res.status === 401) {
if (get(auth).isAuthenticated) {
addToast("Ihre Sitzung ist abgelaufen. Bitte neu anmelden.", "warning");
logout();
}
throw new Error('Sitzung abgelaufen');
}
const errText = await res.text();
let errorMsg = errText || `Fehler: ${res.status}`;
try {
const jsonErr = JSON.parse(errText);
if(jsonErr.message) errorMsg = jsonErr.message;
} catch(e) {}
throw new Error(errorMsg);
}
if (isBlob) return await res.blob();
const text = await res.text();
return text ? JSON.parse(text) : null;
} catch (error) {
loading.set(false);
if (error.message === 'Sitzung abgelaufen') {
throw error;
}
if (error.name === 'TypeError' && error.message.includes('fetch')) {
addToast("Verbindung zum Server fehlgeschlagen. Sind Sie online?", "error");
throw new Error("Verbindungsfehler");
}
if (endpoint !== '/login') {
addToast(error.message || "Unbekannter Fehler", 'error');
}
throw error;
}
}
export const login = async (username, password) => {
try {
const data = await request('/login', 'POST', { username, password });
const jwtData = parseJwt(data.token);
const userObj = {
username: data.username,
is_admin: data.is_admin,
id: jwtData.user_id || jwtData.id || data.id || 0
};
auth.set({ token: data.token, user: mapUserFromApi(userObj), isAuthenticated: true });
addToast("Erfolgreich angemeldet", "success");
return true;
} catch (e) {
addToast("Anmeldung fehlgeschlagen. Prüfen Sie Benutzername und Passwort.", "error");
return false;
}
};
export const logout = () => {
auth.set({ token: null, user: null, isAuthenticated: false });
};
export const getSchedules = async () => {
const data = await request('/schedules');
return data.map(mapScheduleFromApi);
};
export const createSchedule = (s) => {
const payload = mapScheduleToApi(s);
return request('/admin/schedules', 'POST', payload);
};
export const deleteSchedule = (id) => request(`/admin/schedules/delete?id=${id}`, 'DELETE');
export const getMyTimeEntries = async () => {
const data = await request('/my-time-entries');
return data.map(mapTimeEntryFromApi);
};
export const saveTimeEntriesBatch = (entries) => request('/time-entries/batch', 'POST', { entries });
export const deleteWeekEntries = (year, week) => request(`/my-time-entries/week?year=${year}&week=${week}`, 'DELETE');
export const getUsers = async () => {
const data = await request('/admin/users/list');
return data.map(mapUserFromApi);
};
export const createUser = (u) => request('/admin/users', 'POST', { username: u.username, password: u.password, is_admin: u.isAdmin });
export const deleteUser = (id) => request(`/admin/users/delete?id=${id}`, 'DELETE');
export const updateUserWorkHours = (id, hours) => request(`/admin/users/${id}`, 'PUT', { yearly_hours: parseFloat(hours) });
export const resetUserPassword = (id, new_password) => request(`/admin/users/${id}/reset-password`, 'PUT', { new_password });
export const getAllTimeEntries = async () => {
const data = await request('/admin/time-entries');
return data.map(mapTimeEntryFromApi);
};
export const updateTimeEntry = (id, entry) => {
const payload = { date: entry.date, start_time: entry.startTime, end_time: entry.endTime, type: entry.entryType };
return request(`/admin/time-entries/${id}`, 'PUT', payload);
};
export const deleteTimeEntry = (id) => request(`/admin/time-entries/${id}`, 'DELETE');
export const createAdminTimeEntry = (entry) => request('/admin/time-entry', 'POST', {
user_id: entry.selectedUserId, date: entry.date, hours: parseFloat(entry.hours), type: 'manual'
});
export const getYearlySummary = async () => {
const data = await request('/yearly-hours-summary');
return data.map(s => ({ ...s, userId: s.user_id, yearlyTarget: s.yearly_target, yearlyActual: s.yearly_actual, remainingYearly: s.remaining_yearly }));
};
export const downloadYearlySummaryPDF = () => request('/admin/yearly-summary/pdf', 'GET', null, true);
export const getSchoolYears = async () => {
const data = await request('/admin/school-years');
return data.map(sy => ({ ...sy, startDate: sy.start_date, endDate: sy.end_date, isActive: sy.is_active }));
};
export const getActiveSchoolYear = async () => {
const sy = await request('/school-year/active');
if(!sy) return null;
return { ...sy, startDate: sy.start_date, endDate: sy.end_date, isActive: sy.is_active };
};
export const uploadLogo = async (file) => {
const formData = new FormData();
formData.append('logo', file);
const token = localStorage.getItem('token');
const res = await fetch('/api/admin/settings/logo', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
if (!res.ok) throw new Error("Upload fehlgeschlagen");
return true;
};
export const createSchoolYear = (sy) => request('/admin/school-years', 'POST', { name: sy.name, start_date: sy.startDate, end_date: sy.endDate });
export const activateSchoolYear = (id) => request(`/admin/school-years/${id}/activate`, 'PUT');
export const deleteSchoolYear = (id) => request(`/admin/school-years/${id}`, 'DELETE');
export const changeMyPassword = (oldPw, newPw) => request('/change-password', 'POST', { old_password: oldPw, new_password: newPw });

View file

@ -0,0 +1,71 @@
import { writable, get } from 'svelte/store';
function safeParse(jsonString) {
if (!jsonString || jsonString === 'undefined' || jsonString === 'null') return null;
try { return JSON.parse(jsonString); } catch (e) { return null; }
}
function decodeJwt(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''));
return JSON.parse(jsonPayload);
} catch (e) { return null; }
}
const normalizeUser = (u) => {
if (!u) return null;
return { ...u, isAdmin: !!(u.isAdmin || u.is_admin) };
};
const storedToken = localStorage.getItem('token');
let initialUser = normalizeUser(safeParse(localStorage.getItem('user')));
let initialAuth = false;
if (storedToken) {
const decoded = decodeJwt(storedToken);
const currentTime = Date.now() / 1000;
if (decoded && decoded.exp && decoded.exp < currentTime) {
console.warn("Token im Storage ist abgelaufen. Auto-Logout.");
localStorage.removeItem('token');
localStorage.removeItem('user');
initialUser = null;
} else {
initialAuth = !!initialUser;
}
}
export const auth = writable({
token: initialAuth ? storedToken : null,
user: initialUser,
isAuthenticated: initialAuth
});
auth.subscribe(value => {
if (value.token && value.user) {
localStorage.setItem('token', value.token);
localStorage.setItem('user', JSON.stringify(value.user));
} else {
localStorage.removeItem('token');
localStorage.removeItem('user');
}
});
export const loading = writable(false);
export const toasts = writable([]);
export function addToast(message, type = 'info') {
const id = Date.now() + Math.random();
const newToast = { id, message, type };
toasts.update(all => [newToast, ...all]);
setTimeout(() => removeToast(id), 5000);
}
export function removeToast(id) {
toasts.update(all => all.filter(t => t.id !== id));
}

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

@ -0,0 +1,51 @@
export function calculateHours(startTime, endTime) {
if (!startTime || !endTime) return 0;
if (endTime === 'manual') return parseFloat(startTime) || 0;
const parseTime = (timeStr) => {
const parts = timeStr.split(':');
if (parts.length !== 2) return 0;
return parseFloat(parts[0]) + parseFloat(parts[1]) / 60;
};
const start = parseTime(startTime);
const end = parseTime(endTime);
if (end > start) return end - start;
return 0;
}
export function getISOWeek(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
export function getISOYear(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
return d.getUTCFullYear();
}
export function getDateOfISOWeek(w, y) {
const simple = new Date(y, 0, 1 + (w - 1) * 7);
const dow = simple.getDay();
const ISOweekStart = simple;
if (dow <= 4)
ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1);
else
ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay());
return ISOweekStart;
}
export function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export const dayNames = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];

9
frontend/src/main.js Normal file
View file

@ -0,0 +1,9 @@
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
const app = mount(App, {
target: document.getElementById('app'),
});
export default app;

View file

@ -0,0 +1,8 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

View file

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

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

@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
svelte(),
tailwindcss()
],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8085',
changeOrigin: true
}
}
}
})