feat: first test implementation for substitution system
This commit is contained in:
parent
8fe3d71dde
commit
3e2b6d46e6
11 changed files with 1048 additions and 249 deletions
|
|
@ -99,6 +99,17 @@ func createTables(db *sql.DB) {
|
||||||
is_active BOOLEAN NOT NULL DEFAULT 0,
|
is_active BOOLEAN NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS substitutions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
taken_by_user_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(taken_by_user_id) REFERENCES users(id)
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
|
|
@ -114,6 +125,7 @@ func createIndexes(db *sql.DB) {
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)",
|
"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_time_entries_user_date ON time_entries(user_id, date)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
|
"CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)",
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_substitutions_date ON substitutions(date)`,
|
||||||
}
|
}
|
||||||
for _, idx := range indexes {
|
for _, idx := range indexes {
|
||||||
db.Exec(idx)
|
db.Exec(idx)
|
||||||
|
|
@ -577,15 +589,16 @@ func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateHours(entry TimeEntry) float64 {
|
func calculateHours(entry TimeEntry) float64 {
|
||||||
if entry.Type == "lesson" {
|
switch entry.Type {
|
||||||
|
case "lesson":
|
||||||
return 1.0
|
return 1.0
|
||||||
} else if entry.Type == "manual" {
|
case "manual":
|
||||||
hours, err := strconv.ParseFloat(entry.StartTime, 64)
|
hours, err := strconv.ParseFloat(entry.StartTime, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return hours
|
return hours
|
||||||
} else {
|
default:
|
||||||
return calculateHoursDiff(entry.StartTime, entry.EndTime)
|
return calculateHoursDiff(entry.StartTime, entry.EndTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -633,3 +646,107 @@ func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, w
|
||||||
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
|
_, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4])
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateSubstitution(db *sql.DB, date, start, end, title, notes string) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO substitutions (date, start_time, end_time, title, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, date, start, end, title, notes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOpenSubstitutions(db *sql.DB) ([]Substitution, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT id, date, start_time, end_time, title, notes, created_at
|
||||||
|
FROM substitutions
|
||||||
|
WHERE taken_by_user_id IS NULL
|
||||||
|
AND date >= date('now')
|
||||||
|
ORDER BY date ASC, start_time ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var subs []Substitution
|
||||||
|
for rows.Next() {
|
||||||
|
var s Substitution
|
||||||
|
if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &s.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AcceptSubstitution(db *sql.DB, substitutionID int, userID int) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
var currentDate, start, end, title string
|
||||||
|
err = tx.QueryRow(`
|
||||||
|
SELECT date, start_time, end_time, title
|
||||||
|
FROM substitutions
|
||||||
|
WHERE id = ? AND taken_by_user_id IS NULL
|
||||||
|
`, substitutionID).Scan(¤tDate, &start, &end, &title)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("Vertretung wurde bereits vergeben oder existiert nicht")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
UPDATE substitutions
|
||||||
|
SET taken_by_user_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, userID, substitutionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time)
|
||||||
|
VALUES (?, 0, ?, 'lesson', ?, ?)
|
||||||
|
`, userID, currentDate, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAllSubstitutions(db *sql.DB) ([]Substitution, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT s.id, s.date, s.start_time, s.end_time, s.title, s.notes, s.taken_by_user_id, u.username
|
||||||
|
FROM substitutions s
|
||||||
|
LEFT JOIN users u ON s.taken_by_user_id = u.id
|
||||||
|
ORDER BY s.date DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var subs []Substitution
|
||||||
|
for rows.Next() {
|
||||||
|
var s Substitution
|
||||||
|
var takenID sql.NullInt64
|
||||||
|
var takenName sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&s.ID, &s.Date, &s.StartTime, &s.EndTime, &s.Title, &s.Notes, &takenID, &takenName); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if takenID.Valid {
|
||||||
|
id := int(takenID.Int64)
|
||||||
|
s.TakenByUserID = &id
|
||||||
|
s.TakenByUsername = takenName.String
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ module school-timetracker
|
||||||
go 1.25.3
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/jung-kurt/gofpdf v1.16.2
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
|
github.com/labstack/echo-jwt/v4 v4.3.1
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.11.0
|
||||||
|
|
@ -12,9 +14,7 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/labstack/echo-jwt/v4 v4.3.1 // indirect
|
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,16 @@ func (app *App) LoginHandler(c echo.Context) error {
|
||||||
return HandleError(c, ErrInvalidCredentialsMsg())
|
return HandleError(c, ErrInvalidCredentialsMsg())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !user.IsAdmin {
|
||||||
|
_, err := VerifyLicenseFile()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{
|
||||||
|
"error": "Lizenzfehler: " + err.Error(),
|
||||||
|
"code": "LICENSE_INVALID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
token, err := createToken(user.ID, user.Username, user.IsAdmin)
|
token, err := createToken(user.ID, user.Username, user.IsAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrInternalMsg(err))
|
return HandleError(c, ErrInternalMsg(err))
|
||||||
|
|
@ -799,3 +809,106 @@ func (app *App) UploadLogoHandler(c echo.Context) error {
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "Logo erfolgreich hochgeladen"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) GetLicenseStatusHandler(c echo.Context) error {
|
||||||
|
var count int
|
||||||
|
app.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||||
|
|
||||||
|
status := GetCurrentLicenseStatus(nil)
|
||||||
|
status.UserCount = count
|
||||||
|
|
||||||
|
if status.IsValid && status.MaxUsers > 0 && count > status.MaxUsers {
|
||||||
|
status.IsValid = false
|
||||||
|
status.Message = fmt.Sprintf("Benutzerlimit überschritten (%d / %d)", count, status.MaxUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) UploadLicenseHandler(c echo.Context) error {
|
||||||
|
file, err := c.FormFile("license")
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrInvalidInputMsg("Keine Datei"))
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrInternalMsg(err))
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create("license.lic")
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrInternalMsg(err))
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
if _, err = io.Copy(dst, src); err != nil {
|
||||||
|
return HandleError(c, ErrInternalMsg(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := VerifyLicenseFile(); err != nil {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz hochgeladen, aber ungültig: " + err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "Lizenz erfolgreich aktiviert"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) CreateSubstitutionHandler(c echo.Context) error {
|
||||||
|
var req CreateSubstitutionRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return HandleError(c, ErrInvalidInputMsg("Eingabedaten"))
|
||||||
|
}
|
||||||
|
if req.Date == "" || req.StartTime == "" || req.Title == "" {
|
||||||
|
return HandleError(c, ErrMissingFieldMsg("Pflichtfelder"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CreateSubstitution(app.DB, req.Date, req.StartTime, req.EndTime, req.Title, req.Notes); err != nil {
|
||||||
|
return HandleError(c, ErrDatabaseMsg(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, map[string]string{"message": "Vertretung ausgeschrieben"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) GetAllSubstitutionsHandler(c echo.Context) error {
|
||||||
|
subs, err := GetAllSubstitutions(app.DB)
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrDatabaseMsg(err))
|
||||||
|
}
|
||||||
|
if subs == nil {
|
||||||
|
subs = []Substitution{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) GetOpenSubstitutionsHandler(c echo.Context) error {
|
||||||
|
subs, err := GetOpenSubstitutions(app.DB)
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrDatabaseMsg(err))
|
||||||
|
}
|
||||||
|
if subs == nil {
|
||||||
|
subs = []Substitution{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) AcceptSubstitutionHandler(c echo.Context) error {
|
||||||
|
claims, err := getClaims(c)
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrUnauthorizedMsg())
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return HandleError(c, ErrInvalidInputMsg("ID"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := AcceptSubstitution(app.DB, id, claims.UserID); err != nil {
|
||||||
|
if err.Error() == "Vertretung wurde bereits vergeben oder existiert nicht" {
|
||||||
|
return HandleError(c, ErrAlreadyExistsMsg("Diese Vertretung ist leider schon vergeben"))
|
||||||
|
}
|
||||||
|
return HandleError(c, ErrDatabaseMsg(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "Vertretung erfolgreich übernommen!"})
|
||||||
|
}
|
||||||
|
|
|
||||||
72
backend/license.go
Normal file
72
backend/license.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const PublicKeyHex = "ab8287380d4f26b66b3e8067e179a2e304dcb3c4070963edd213cca9b225978f"
|
||||||
|
|
||||||
|
func VerifyLicenseFile() (*LicenseData, error) {
|
||||||
|
bytes, err := os.ReadFile("license.lic")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Keine Lizenzdatei gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
var lic LicenseFile
|
||||||
|
if err := json.Unmarshal(bytes, &lic); err != nil {
|
||||||
|
return nil, fmt.Errorf("Lizenzdatei beschädigt")
|
||||||
|
}
|
||||||
|
|
||||||
|
pubBytes, _ := hex.DecodeString(PublicKeyHex)
|
||||||
|
publicKey := ed25519.PublicKey(pubBytes)
|
||||||
|
|
||||||
|
dataBytes, _ := json.Marshal(lic.Data)
|
||||||
|
sigBytes, err := base64.StdEncoding.DecodeString(lic.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Signatur ungültig")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ed25519.Verify(publicKey, dataBytes, sigBytes) {
|
||||||
|
return nil, fmt.Errorf("Lizenz-Signatur ungültig (Manipuliert?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry, err := time.Parse("2006-01-02", lic.Data.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Ungültiges Datumsformat")
|
||||||
|
}
|
||||||
|
if time.Now().After(expiry) {
|
||||||
|
return &lic.Data, fmt.Errorf("Lizenz abgelaufen am %s", lic.Data.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &lic.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCurrentLicenseStatus(db *any) LicenseStatus {
|
||||||
|
lic, err := VerifyLicenseFile()
|
||||||
|
status := LicenseStatus{
|
||||||
|
IsValid: err == nil,
|
||||||
|
Message: "Gültig",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
status.Message = err.Error()
|
||||||
|
if lic != nil {
|
||||||
|
status.SchoolName = lic.SchoolName
|
||||||
|
status.ExpiresAt = lic.ExpiresAt
|
||||||
|
status.MaxUsers = lic.MaxUsers
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
status.SchoolName = lic.SchoolName
|
||||||
|
status.ExpiresAt = lic.ExpiresAt
|
||||||
|
status.MaxUsers = lic.MaxUsers
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,8 @@ func main() {
|
||||||
protected.GET("/my-info", app.GetMyInfoHandler)
|
protected.GET("/my-info", app.GetMyInfoHandler)
|
||||||
protected.POST("/change-password", app.ChangeMyPasswordHandler)
|
protected.POST("/change-password", app.ChangeMyPasswordHandler)
|
||||||
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
|
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
|
||||||
|
protected.GET("/substitutions/open", app.GetOpenSubstitutionsHandler)
|
||||||
|
protected.POST("/substitutions/:id/accept", app.AcceptSubstitutionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
admin := e.Group("/api/admin")
|
admin := e.Group("/api/admin")
|
||||||
|
|
@ -94,6 +96,10 @@ func main() {
|
||||||
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
|
admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler)
|
||||||
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
|
admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler)
|
||||||
admin.POST("/settings/logo", app.UploadLogoHandler)
|
admin.POST("/settings/logo", app.UploadLogoHandler)
|
||||||
|
admin.GET("/settings/license", app.GetLicenseStatusHandler)
|
||||||
|
admin.POST("/settings/license", app.UploadLicenseHandler)
|
||||||
|
admin.GET("/substitutions", app.GetAllSubstitutionsHandler)
|
||||||
|
admin.POST("/substitutions", app.CreateSubstitutionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
distDir, err := fs.Sub(frontendDist, "dist")
|
distDir, err := fs.Sub(frontendDist, "dist")
|
||||||
|
|
|
||||||
|
|
@ -106,3 +106,43 @@ type ChangePasswordRequest struct {
|
||||||
OldPassword string `json:"old_password"`
|
OldPassword string `json:"old_password"`
|
||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LicenseData struct {
|
||||||
|
SchoolName string `json:"school_name"`
|
||||||
|
MaxUsers int `json:"max_users"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseFile struct {
|
||||||
|
Data LicenseData `json:"data"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseStatus struct {
|
||||||
|
IsValid bool `json:"is_valid"`
|
||||||
|
SchoolName string `json:"school_name"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
MaxUsers int `json:"max_users"`
|
||||||
|
UserCount int `json:"user_count"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Substitution struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
EndTime string `json:"end_time"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
TakenByUserID *int `json:"taken_by_user_id,omitempty"`
|
||||||
|
TakenByUsername string `json:"taken_by_username,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateSubstitutionRequest struct {
|
||||||
|
Date string `json:"date" validate:"required"`
|
||||||
|
StartTime string `json:"start_time" validate:"required"`
|
||||||
|
EndTime string `json:"end_time" validate:"required"`
|
||||||
|
Title string `json:"title" validate:"required"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import AdminTimeEntriesTab from "./admin/AdminTimeEntriesTab.svelte";
|
import AdminTimeEntriesTab from "./admin/AdminTimeEntriesTab.svelte";
|
||||||
import AdminSchoolYearsTab from "./admin/AdminSchoolYearsTab.svelte";
|
import AdminSchoolYearsTab from "./admin/AdminSchoolYearsTab.svelte";
|
||||||
import AdminSettingsTab from "./admin/AdminSettingsTab.svelte";
|
import AdminSettingsTab from "./admin/AdminSettingsTab.svelte";
|
||||||
|
import AdminSubstitutionsTab from "./admin/AdminSubstitutionsTab.svelte";
|
||||||
|
|
||||||
let activeTab = "schedule";
|
let activeTab = "schedule";
|
||||||
const user = $auth.user;
|
const user = $auth.user;
|
||||||
|
|
@ -25,6 +26,8 @@
|
||||||
return "Schuljahre & Perioden";
|
return "Schuljahre & Perioden";
|
||||||
case "settings":
|
case "settings":
|
||||||
return "Einstellungen";
|
return "Einstellungen";
|
||||||
|
case "substitutions":
|
||||||
|
return "Vertretungen";
|
||||||
default:
|
default:
|
||||||
return "Admin";
|
return "Admin";
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +124,8 @@
|
||||||
<AdminSchoolYearsTab />
|
<AdminSchoolYearsTab />
|
||||||
{:else if activeTab === "settings"}
|
{:else if activeTab === "settings"}
|
||||||
<AdminSettingsTab />
|
<AdminSettingsTab />
|
||||||
|
{:else if activeTab === "substitutions"}
|
||||||
|
<AdminSubstitutionsTab />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -277,6 +282,32 @@
|
||||||
Schuljahre
|
Schuljahre
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class={activeTab === "substitutions"
|
||||||
|
? "active bg-primary/10 text-primary"
|
||||||
|
: ""}
|
||||||
|
on:click={() => {
|
||||||
|
activeTab = "substitutions";
|
||||||
|
closeDrawer();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
Vertretungen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1"
|
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
deleteWeekEntries,
|
deleteWeekEntries,
|
||||||
changeMyPassword,
|
changeMyPassword,
|
||||||
getMyInfo,
|
getMyInfo,
|
||||||
|
getOpenSubstitutions,
|
||||||
|
acceptSubstitution,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import {
|
import {
|
||||||
getISOWeek,
|
getISOWeek,
|
||||||
|
|
@ -36,6 +38,10 @@
|
||||||
let showPwModal = false;
|
let showPwModal = false;
|
||||||
let pwData = { old: "", new1: "", new2: "" };
|
let pwData = { old: "", new1: "", new2: "" };
|
||||||
|
|
||||||
|
// State für die Ansicht
|
||||||
|
let activeView = "schedule";
|
||||||
|
let openSubstitutions = [];
|
||||||
|
|
||||||
$: hasEntriesForWeek = existingEntries.some((e) => e.entryType !== "manual");
|
$: hasEntriesForWeek = existingEntries.some((e) => e.entryType !== "manual");
|
||||||
|
|
||||||
$: weekDates = Array.from({ length: 5 }, (_, i) => {
|
$: weekDates = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
|
@ -77,6 +83,8 @@
|
||||||
await deleteWeekEntries(currentISOYear, currentWeek);
|
await deleteWeekEntries(currentISOYear, currentWeek);
|
||||||
addToast("Woche erfolgreich zurückgesetzt", "success");
|
addToast("Woche erfolgreich zurückgesetzt", "success");
|
||||||
weekEditMode = false;
|
weekEditMode = false;
|
||||||
|
selectedEntries = [];
|
||||||
|
existingEntries = [];
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
@ -88,13 +96,16 @@
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoadingData = true;
|
isLoadingData = true;
|
||||||
try {
|
try {
|
||||||
const [schedulesData, entriesData, userData] = await Promise.all([
|
const [schedulesData, entriesData, userData, subsData] =
|
||||||
getSchedules(),
|
await Promise.all([
|
||||||
getMyTimeEntries(),
|
getSchedules(),
|
||||||
getMyInfo(),
|
getMyTimeEntries(),
|
||||||
]);
|
getMyInfo(),
|
||||||
|
getOpenSubstitutions(),
|
||||||
|
]);
|
||||||
schedules = schedulesData;
|
schedules = schedulesData;
|
||||||
allEntries = entriesData;
|
allEntries = entriesData;
|
||||||
|
openSubstitutions = subsData;
|
||||||
|
|
||||||
auth.update((current) => ({
|
auth.update((current) => ({
|
||||||
...current,
|
...current,
|
||||||
|
|
@ -107,6 +118,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAcceptSub(sub) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`Möchten Sie die Vertretung "${sub.title}" am ${sub.date} übernehmen?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await acceptSubstitution(sub.id);
|
||||||
|
addToast("Erfolgreich übernommen! Stunden wurden gebucht.", "success");
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToWeekOfDate(dateStr) {
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number);
|
||||||
|
const targetDate = new Date(y, m - 1, d);
|
||||||
|
|
||||||
|
const targetWeek = getISOWeek(targetDate);
|
||||||
|
const targetYear = getISOYear(targetDate);
|
||||||
|
|
||||||
|
currentISOYear = targetYear;
|
||||||
|
currentWeek = targetWeek;
|
||||||
|
|
||||||
|
activeView = "schedule";
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
function filterEntries(entries) {
|
function filterEntries(entries) {
|
||||||
existingEntries = entries.filter((e) => {
|
existingEntries = entries.filter((e) => {
|
||||||
const [y, m, d] = e.date.split("-").map(Number);
|
const [y, m, d] = e.date.split("-").map(Number);
|
||||||
|
|
@ -221,10 +263,14 @@
|
||||||
<div class="text-sm breadcrumbs hidden sm:block">
|
<div class="text-sm breadcrumbs hidden sm:block">
|
||||||
<ul>
|
<ul>
|
||||||
<li class="opacity-50">Mein Bereich</li>
|
<li class="opacity-50">Mein Bereich</li>
|
||||||
<li class="font-bold text-primary">Stundenplan</li>
|
<li class="font-bold text-primary">
|
||||||
|
{activeView === "schedule" ? "Stundenplan" : "Vertretungsbörse"}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-bold text-lg sm:hidden">KW {currentWeek}</span>
|
<span class="font-bold text-lg sm:hidden">
|
||||||
|
{activeView === "schedule" ? `KW ${currentWeek}` : "Vertretungen"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-none flex items-center gap-4">
|
<div class="flex-none flex items-center gap-4">
|
||||||
|
|
@ -259,263 +305,346 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 md:p-8 lg:p-10 fade-in space-y-6">
|
<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">
|
{#if activeView === "schedule"}
|
||||||
<div class="card-body p-2 sm:p-4 flex-row items-center justify-between">
|
<div class="card bg-base-100 shadow-sm border border-base-200">
|
||||||
<button
|
<div
|
||||||
class="btn btn-circle btn-ghost"
|
class="card-body p-2 sm:p-4 flex-row items-center justify-between"
|
||||||
on:click={() => changeWeek(-1)}
|
|
||||||
disabled={isLoadingData}
|
|
||||||
>
|
>
|
||||||
<svg
|
<button
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="btn btn-circle btn-ghost"
|
||||||
fill="none"
|
on:click={() => changeWeek(-1)}
|
||||||
viewBox="0 0 24 24"
|
disabled={isLoadingData}
|
||||||
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>
|
<svg
|
||||||
<div class="text-center">
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<p
|
fill="none"
|
||||||
class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1"
|
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}
|
||||||
>
|
>
|
||||||
Kalenderwoche
|
<svg
|
||||||
</p>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<h2 class="text-xl sm:text-3xl font-bold">
|
fill="none"
|
||||||
KW {currentWeek} <span class="text-base-content/30">/</span>
|
viewBox="0 0 24 24"
|
||||||
{currentISOYear}
|
stroke-width="1.5"
|
||||||
</h2>
|
stroke="currentColor"
|
||||||
<p
|
class="w-6 h-6"
|
||||||
class="text-xs sm:text-sm opacity-60 mt-1 font-mono bg-base-200 inline-block px-2 py-1 rounded"
|
><path
|
||||||
>
|
stroke-linecap="round"
|
||||||
{weekDates[0]?.date} — {weekDates[4]?.date}
|
stroke-linejoin="round"
|
||||||
</p>
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<div class="lg:hidden grid grid-cols-2 gap-2">
|
<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="stats shadow-sm bg-base-100 border border-base-200 py-1">
|
||||||
<div class="stat p-2 text-center">
|
<div class="stat p-2 text-center">
|
||||||
<div class="stat-desc">Geleistet</div>
|
<div class="stat-desc">Geleistet</div>
|
||||||
<div class="stat-value text-lg">{yearlyTotal.toFixed(1)}</div>
|
<div class="stat-value text-lg">{yearlyTotal.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
|
||||||
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
|
<div class="stat p-2 text-center">
|
||||||
<div class="stat p-2 text-center">
|
<div class="stat-desc">Offen</div>
|
||||||
<div class="stat-desc">Offen</div>
|
<div
|
||||||
<div
|
class="stat-value text-lg {remaining <= 0
|
||||||
class="stat-value text-lg {remaining <= 0
|
? 'text-success'
|
||||||
? 'text-success'
|
: 'text-warning'}"
|
||||||
: 'text-warning'}"
|
>
|
||||||
>
|
{Math.max(0, remaining).toFixed(1)}
|
||||||
{Math.max(0, remaining).toFixed(1)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isLoadingData}
|
{#if isLoadingData}
|
||||||
<div
|
<div
|
||||||
class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"
|
class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"
|
||||||
>
|
>
|
||||||
<div class="flex gap-4">
|
<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 _}
|
{#each Array(5) as _}
|
||||||
<div class="flex-1 space-y-4">
|
<div class="skeleton h-16 w-full rounded-lg"></div>
|
||||||
<div class="skeleton h-8 w-full mb-4"></div>
|
{/each}
|
||||||
<div class="skeleton h-20 w-full rounded"></div>
|
</div>
|
||||||
<div class="skeleton h-20 w-full rounded"></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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if activeView === "market"}
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl font-bold">Offene Vertretungen</h2>
|
||||||
|
<button class="btn btn-ghost btn-sm" on:click={loadData}
|
||||||
|
><i class="fas fa-sync"></i> Refresh</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="lg:hidden space-y-4">
|
|
||||||
{#each Array(5) as _}
|
{#if openSubstitutions.length === 0}
|
||||||
<div class="skeleton h-16 w-full rounded-lg"></div>
|
<div class="hero bg-base-100 rounded-xl border border-base-200 py-12">
|
||||||
{/each}
|
<div class="hero-content text-center">
|
||||||
</div>
|
<div class="max-w-md">
|
||||||
{:else}
|
<h1 class="text-xl font-bold opacity-50">Alles ruhig</h1>
|
||||||
{#if hasEntriesForWeek && !weekEditMode}
|
<p class="py-6 opacity-70">
|
||||||
<div role="alert" class="alert alert-success shadow-sm">
|
Aktuell werden keine Vertretungen gesucht.
|
||||||
<svg
|
</p>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</div>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div role="alert" class="alert alert-info shadow-sm">
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
<svg
|
{#each openSubstitutions as sub}
|
||||||
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
|
<div
|
||||||
class="collapse-title text-lg font-medium flex justify-between items-center"
|
class="card bg-base-100 shadow-xl border border-base-200 hover:border-primary transition-all"
|
||||||
>
|
>
|
||||||
<span>{day.name}</span>
|
<div class="card-body">
|
||||||
<span class="text-sm opacity-50 font-mono">{day.date}</span>
|
<div class="flex justify-between items-start">
|
||||||
</div>
|
<h3 class="card-title text-primary">{sub.title}</h3>
|
||||||
<div class="collapse-content">
|
<div class="badge badge-outline font-mono">{sub.date}</div>
|
||||||
<div class="pt-2 space-y-2">
|
</div>
|
||||||
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
|
|
||||||
<ScheduleItem
|
<p class="text-2xl font-bold my-2">
|
||||||
{schedule}
|
{sub.start_time}
|
||||||
dayOfWeek={day.dayIndex}
|
<span class="text-base font-normal opacity-50"
|
||||||
isSelected={selectedEntries.some(
|
>- {sub.end_time}</span
|
||||||
(e) =>
|
>
|
||||||
e.scheduleId === schedule.id &&
|
</p>
|
||||||
e.dayOfWeek === day.dayIndex,
|
|
||||||
)}
|
{#if sub.notes}
|
||||||
isClickable={(!hasEntriesForWeek || weekEditMode) &&
|
<div
|
||||||
!processing}
|
class="alert alert-ghost text-sm py-2 px-3 my-2 bg-base-200/50"
|
||||||
on:toggle={() =>
|
>
|
||||||
toggleSelection(schedule.id, day.dayIndex)}
|
<svg
|
||||||
/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{/each}
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="stroke-info shrink-0 w-4 h-4"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path></svg
|
||||||
|
>
|
||||||
|
<span>{sub.notes}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4 items-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost text-xs"
|
||||||
|
on:click={() => jumpToWeekOfDate(sub.date)}
|
||||||
|
>
|
||||||
|
Im Plan prüfen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
on:click={() => handleAcceptSub(sub)}
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
|
{#if activeView === "schedule" && (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
|
||||||
<div
|
<div
|
||||||
class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none"
|
class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none"
|
||||||
>
|
>
|
||||||
|
|
@ -561,6 +690,7 @@
|
||||||
User Dashboard
|
User Dashboard
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="menu p-4 w-full gap-2 text-base font-medium">
|
<ul class="menu p-4 w-full gap-2 text-base font-medium">
|
||||||
<li
|
<li
|
||||||
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
|
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
|
||||||
|
|
@ -568,10 +698,14 @@
|
||||||
Navigation
|
Navigation
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<button
|
||||||
class="active bg-primary/10 text-primary"
|
class={activeView === "schedule"
|
||||||
href="#"
|
? "active bg-primary/10 text-primary"
|
||||||
on:click={() => closeDrawer()}
|
: ""}
|
||||||
|
on:click={() => {
|
||||||
|
activeView = "schedule";
|
||||||
|
closeDrawer();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
@ -587,7 +721,38 @@
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
Stundenplan
|
Stundenplan
|
||||||
</a>
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class={activeView === "market"
|
||||||
|
? "active bg-primary/10 text-primary"
|
||||||
|
: ""}
|
||||||
|
on:click={() => {
|
||||||
|
activeView = "market";
|
||||||
|
closeDrawer();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="w-5 h-5"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M10.05 4.575a1.575 1.575 0 10-3.15 0v3m3.15-3v-1.5a1.575 1.575 0 013.15 0v1.5m-3.15 0l.075 5.925m3.075.75V4.575m0 0a1.575 1.575 0 013.15 0V15M6.9 7.575V12.75M5.25 21h13.5c1.125 0 2.025-.9 2.025-2.025v-9.75c0-1.125-.9-2.025-2.025-2.025H5.25A2.25 2.25 0 003 9.375v9.75c0 1.125.9 2.025 2.025 2.025z"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
Vertretungsbörse
|
||||||
|
{#if openSubstitutions.length > 0}
|
||||||
|
<span class="badge badge-sm badge-secondary ml-auto"
|
||||||
|
>{openSubstitutions.length}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button on:click={() => (showPwModal = true)}>
|
<button on:click={() => (showPwModal = true)}>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,34 @@
|
||||||
<script>
|
<script>
|
||||||
import { uploadLogo } from "../../lib/api";
|
import { uploadLogo, getLicenseStatus, uploadLicense } from "../../lib/api";
|
||||||
import { addToast } from "../../lib/stores";
|
import { addToast } from "../../lib/stores";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let fileInput;
|
let fileInput;
|
||||||
let previewSrc = "/api/logo?t=" + Date.now();
|
let previewSrc = "/api/logo?t=" + Date.now();
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
|
let licenseStatus = null;
|
||||||
|
let licFile;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
loadLicense();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLicense() {
|
||||||
|
try {
|
||||||
|
licenseStatus = await getLicenseStatus();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLicenseUpload() {
|
||||||
|
if (!licFile.files[0]) return;
|
||||||
|
try {
|
||||||
|
await uploadLicense(licFile.files[0]);
|
||||||
|
addToast("Lizenzdatei hochgeladen", "success");
|
||||||
|
loadLicense();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFileChange(e) {
|
async function handleFileChange(e) {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
|
|
@ -74,3 +98,57 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 max-w-2xl mt-8">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">Lizenzierung</h3>
|
||||||
|
|
||||||
|
{#if licenseStatus}
|
||||||
|
<div
|
||||||
|
class="stats stats-vertical lg:stats-horizontal shadow mb-4 border border-base-200"
|
||||||
|
>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Status</div>
|
||||||
|
<div
|
||||||
|
class="stat-value text-lg {licenseStatus.is_valid
|
||||||
|
? 'text-success'
|
||||||
|
: 'text-error'}"
|
||||||
|
>
|
||||||
|
{licenseStatus.is_valid ? "Aktiv" : "Ungültig"}
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc">{licenseStatus.message}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Schule</div>
|
||||||
|
<div class="stat-value text-lg">
|
||||||
|
{licenseStatus.school_name || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">Gültig bis</div>
|
||||||
|
<div class="stat-value text-lg">
|
||||||
|
{licenseStatus.expires_at || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-bold"
|
||||||
|
>Lizenzdatei einspielen (.lic)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".lic"
|
||||||
|
class="file-input file-input-bordered w-full"
|
||||||
|
bind:this={licFile}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" on:click={handleLicenseUpload}
|
||||||
|
>Hochladen</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
144
frontend/src/components/admin/AdminSubstitutionsTab.svelte
Normal file
144
frontend/src/components/admin/AdminSubstitutionsTab.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { getAllSubstitutions, createSubstitution } from "../../lib/api";
|
||||||
|
import { addToast, loading } from "../../lib/stores";
|
||||||
|
|
||||||
|
let substitutions = [];
|
||||||
|
let newSub = { date: "", startTime: "", endTime: "", title: "", notes: "" };
|
||||||
|
|
||||||
|
onMount(loadData);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
substitutions = await getAllSubstitutions();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newSub.date || !newSub.startTime || !newSub.title) {
|
||||||
|
addToast("Bitte Pflichtfelder ausfüllen", "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await createSubstitution(newSub);
|
||||||
|
addToast("Vertretung ausgeschrieben", "success");
|
||||||
|
newSub = { date: "", startTime: "", endTime: "", title: "", notes: "" };
|
||||||
|
await loadData();
|
||||||
|
} catch (e) {
|
||||||
|
addToast(e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">Neue Vertretung ausschreiben</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">Titel / Klasse</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
placeholder="z.B. Mathe 4a"
|
||||||
|
bind:value={newSub.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">Datum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered"
|
||||||
|
bind:value={newSub.date}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">Von</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered"
|
||||||
|
bind:value={newSub.startTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">Bis</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered"
|
||||||
|
bind:value={newSub.endTime}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">Notizen (Optional)</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered"
|
||||||
|
placeholder="z.B. Arbeitsblätter im Fach..."
|
||||||
|
bind:value={newSub.notes}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-full mt-4"
|
||||||
|
on:click={handleCreate}
|
||||||
|
disabled={$loading}
|
||||||
|
>
|
||||||
|
Veröffentlichen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<h3 class="font-bold text-xl mb-4">Übersicht</h3>
|
||||||
|
<div
|
||||||
|
class="overflow-x-auto bg-base-100 rounded-lg shadow border border-base-200"
|
||||||
|
>
|
||||||
|
<table class="table w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Zeit</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each substitutions as s}
|
||||||
|
<tr class="hover">
|
||||||
|
<td class="whitespace-nowrap font-mono text-sm">{s.date}</td>
|
||||||
|
<td>{s.start_time} - {s.end_time}</td>
|
||||||
|
<td>
|
||||||
|
<div class="font-bold">{s.title}</div>
|
||||||
|
{#if s.notes}<div class="text-xs opacity-60 truncate max-w-xs">
|
||||||
|
{s.notes}
|
||||||
|
</div>{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if s.taken_by_user_id}
|
||||||
|
<span class="badge badge-success gap-2"> Übernommen </span>
|
||||||
|
<div class="text-xs mt-1 opacity-70">
|
||||||
|
von {s.taken_by_username}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-warning badge-outline">Offen</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr
|
||||||
|
><td colspan="4" class="text-center opacity-50 py-8"
|
||||||
|
>Keine Einträge</td
|
||||||
|
></tr
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -304,3 +304,36 @@ export const changeMyPassword = (oldPw, newPw) =>
|
||||||
old_password: oldPw,
|
old_password: oldPw,
|
||||||
new_password: newPw,
|
new_password: newPw,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getLicenseStatus = async () => request("/admin/settings/license");
|
||||||
|
|
||||||
|
export const uploadLicense = async (file) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("license", file);
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const res = await fetch("/api/admin/settings/license", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Upload fehlgeschlagen");
|
||||||
|
return await res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllSubstitutions = async () => {
|
||||||
|
const data = await request("/admin/substitutions");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSubstitution = (sub) => {
|
||||||
|
return request("/admin/substitutions", "POST", sub);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOpenSubstitutions = async () => {
|
||||||
|
const data = await request("/substitutions/open");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acceptSubstitution = (id) => {
|
||||||
|
return request(`/substitutions/${id}/accept`, "POST");
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue