feat: first test implementation for substitution system

This commit is contained in:
Patryk Hegenberg 2026-01-16 13:03:01 +01:00
parent 8fe3d71dde
commit 3e2b6d46e6
11 changed files with 1048 additions and 249 deletions

View file

@ -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(&currentDate, &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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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