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

@ -7,6 +7,7 @@
import AdminTimeEntriesTab from "./admin/AdminTimeEntriesTab.svelte";
import AdminSchoolYearsTab from "./admin/AdminSchoolYearsTab.svelte";
import AdminSettingsTab from "./admin/AdminSettingsTab.svelte";
import AdminSubstitutionsTab from "./admin/AdminSubstitutionsTab.svelte";
let activeTab = "schedule";
const user = $auth.user;
@ -25,6 +26,8 @@
return "Schuljahre & Perioden";
case "settings":
return "Einstellungen";
case "substitutions":
return "Vertretungen";
default:
return "Admin";
}
@ -121,6 +124,8 @@
<AdminSchoolYearsTab />
{:else if activeTab === "settings"}
<AdminSettingsTab />
{:else if activeTab === "substitutions"}
<AdminSubstitutionsTab />
{/if}
</div>
</div>
@ -277,6 +282,32 @@
Schuljahre
</button>
</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
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-4 mb-1"
>

View file

@ -9,6 +9,8 @@
deleteWeekEntries,
changeMyPassword,
getMyInfo,
getOpenSubstitutions,
acceptSubstitution,
} from "../lib/api";
import {
getISOWeek,
@ -36,6 +38,10 @@
let showPwModal = false;
let pwData = { old: "", new1: "", new2: "" };
// State für die Ansicht
let activeView = "schedule";
let openSubstitutions = [];
$: hasEntriesForWeek = existingEntries.some((e) => e.entryType !== "manual");
$: weekDates = Array.from({ length: 5 }, (_, i) => {
@ -77,6 +83,8 @@
await deleteWeekEntries(currentISOYear, currentWeek);
addToast("Woche erfolgreich zurückgesetzt", "success");
weekEditMode = false;
selectedEntries = [];
existingEntries = [];
await loadData();
} catch (e) {
console.error(e);
@ -88,13 +96,16 @@
async function loadData() {
isLoadingData = true;
try {
const [schedulesData, entriesData, userData] = await Promise.all([
getSchedules(),
getMyTimeEntries(),
getMyInfo(),
]);
const [schedulesData, entriesData, userData, subsData] =
await Promise.all([
getSchedules(),
getMyTimeEntries(),
getMyInfo(),
getOpenSubstitutions(),
]);
schedules = schedulesData;
allEntries = entriesData;
openSubstitutions = subsData;
auth.update((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) {
existingEntries = entries.filter((e) => {
const [y, m, d] = e.date.split("-").map(Number);
@ -221,10 +263,14 @@
<div class="text-sm breadcrumbs hidden sm:block">
<ul>
<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>
</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 class="flex-none flex items-center gap-4">
@ -259,263 +305,346 @@
</div>
<div class="p-4 md:p-8 lg:p-10 fade-in space-y-6">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-2 sm:p-4 flex-row items-center justify-between">
<button
class="btn btn-circle btn-ghost"
on:click={() => changeWeek(-1)}
disabled={isLoadingData}
{#if activeView === "schedule"}
<div class="card bg-base-100 shadow-sm border border-base-200">
<div
class="card-body p-2 sm:p-4 flex-row items-center justify-between"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/></svg
<button
class="btn btn-circle btn-ghost"
on:click={() => changeWeek(-1)}
disabled={isLoadingData}
>
</button>
<div class="text-center">
<p
class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1"
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/></svg
>
</button>
<div class="text-center">
<p
class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1"
>
Kalenderwoche
</p>
<h2 class="text-xl sm:text-3xl font-bold">
KW {currentWeek} <span class="text-base-content/30">/</span>
{currentISOYear}
</h2>
<p
class="text-xs sm:text-sm opacity-60 mt-1 font-mono bg-base-200 inline-block px-2 py-1 rounded"
>
{weekDates[0]?.date}{weekDates[4]?.date}
</p>
</div>
<button
class="btn btn-circle btn-ghost"
on:click={() => changeWeek(1)}
disabled={isLoadingData}
>
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>
<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>
<button
class="btn btn-circle btn-ghost"
on:click={() => changeWeek(1)}
disabled={isLoadingData}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/></svg
>
</button>
</div>
</div>
<div class="lg:hidden grid grid-cols-2 gap-2">
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Geleistet</div>
<div class="stat-value text-lg">{yearlyTotal.toFixed(1)}</div>
<div class="lg:hidden grid grid-cols-2 gap-2">
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Geleistet</div>
<div class="stat-value text-lg">{yearlyTotal.toFixed(1)}</div>
</div>
</div>
</div>
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Offen</div>
<div
class="stat-value text-lg {remaining <= 0
? 'text-success'
: 'text-warning'}"
>
{Math.max(0, remaining).toFixed(1)}
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Offen</div>
<div
class="stat-value text-lg {remaining <= 0
? 'text-success'
: 'text-warning'}"
>
{Math.max(0, remaining).toFixed(1)}
</div>
</div>
</div>
</div>
</div>
{#if isLoadingData}
<div
class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"
>
<div class="flex gap-4">
{#if isLoadingData}
<div
class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"
>
<div class="flex gap-4">
{#each Array(5) as _}
<div class="flex-1 space-y-4">
<div class="skeleton h-8 w-full mb-4"></div>
<div class="skeleton h-20 w-full rounded"></div>
<div class="skeleton h-20 w-full rounded"></div>
</div>
{/each}
</div>
</div>
<div class="lg:hidden space-y-4">
{#each Array(5) as _}
<div class="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 class="skeleton h-16 w-full rounded-lg"></div>
{/each}
</div>
{:else}
{#if hasEntriesForWeek && !weekEditMode}
<div role="alert" class="alert alert-success shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<div>
<h3 class="font-bold">Erfasst!</h3>
<div class="text-xs">Stunden gespeichert.</div>
</div>
<button
class="btn btn-sm btn-outline"
on:click={() => (weekEditMode = true)}
disabled={processing}>Bearbeiten</button
>
</div>
{:else if weekEditMode}
<div role="alert" class="alert alert-warning shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/></svg
>
<div>
<h3 class="font-bold">Bearbeitungsmodus</h3>
<div class="text-xs">Speichern nicht vergessen!</div>
</div>
<div class="flex gap-2">
<button
class="btn btn-sm btn-error"
on:click={handleDeleteWeek}
disabled={processing}>Löschen</button
>
<button
class="btn btn-sm btn-ghost"
on:click={() => (weekEditMode = false)}>Abbrechen</button
>
</div>
</div>
{:else}
<div role="alert" class="alert alert-info shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path></svg
>
<div>
<h3 class="font-bold">Zeiterfassung</h3>
<div class="text-xs">Wählen Sie Ihre Stunden.</div>
</div>
</div>
{/if}
<div
class="overflow-x-auto hidden lg:block bg-base-100 rounded-2xl shadow-xl border border-base-200"
>
<table class="table table-fixed w-full">
<thead>
<tr>
{#each weekDates as day}
<th class="text-center bg-base-200/50 py-4">
<div class="font-bold text-lg">{day.name}</div>
<div class="text-xs font-normal opacity-50">
{day.date}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
<tr>
{#each weekDates as day}
<td
class="align-top p-2 min-w-[160px] border-r border-base-200 last:border-0"
>
<div class="space-y-2">
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem
{schedule}
dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(
(e) =>
e.scheduleId === schedule.id &&
e.dayOfWeek === day.dayIndex,
)}
isClickable={(!hasEntriesForWeek || weekEditMode) &&
!processing}
on:toggle={() =>
toggleSelection(schedule.id, day.dayIndex)}
/>
{/each}
</div>
</td>
{/each}
</tr>
</tbody>
</table>
</div>
<div class="lg:hidden space-y-4">
{#each weekDates as day}
<div
class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200"
>
<input type="checkbox" />
<div
class="collapse-title text-lg font-medium flex justify-between items-center"
>
<span>{day.name}</span>
<span class="text-sm opacity-50 font-mono">{day.date}</span>
</div>
<div class="collapse-content">
<div class="pt-2 space-y-2">
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem
{schedule}
dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(
(e) =>
e.scheduleId === schedule.id &&
e.dayOfWeek === day.dayIndex,
)}
isClickable={(!hasEntriesForWeek || weekEditMode) &&
!processing}
on:toggle={() =>
toggleSelection(schedule.id, day.dayIndex)}
/>
{/each}
</div>
</div>
</div>
{/each}
</div>
{/if}
{: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 class="lg:hidden space-y-4">
{#each Array(5) as _}
<div class="skeleton h-16 w-full rounded-lg"></div>
{/each}
</div>
{:else}
{#if hasEntriesForWeek && !weekEditMode}
<div role="alert" class="alert alert-success shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<div>
<h3 class="font-bold">Erfasst!</h3>
<div class="text-xs">Stunden gespeichert.</div>
</div>
<button
class="btn btn-sm btn-outline"
on:click={() => (weekEditMode = true)}
disabled={processing}>Bearbeiten</button
>
</div>
{:else if weekEditMode}
<div role="alert" class="alert alert-warning shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/></svg
>
<div>
<h3 class="font-bold">Bearbeitungsmodus</h3>
<div class="text-xs">Speichern nicht vergessen!</div>
</div>
<div class="flex gap-2">
<button
class="btn btn-sm btn-error"
on:click={handleDeleteWeek}
disabled={processing}>Löschen</button
>
<button
class="btn btn-sm btn-ghost"
on:click={() => (weekEditMode = false)}>Abbrechen</button
>
{#if openSubstitutions.length === 0}
<div class="hero bg-base-100 rounded-xl border border-base-200 py-12">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-xl font-bold opacity-50">Alles ruhig</h1>
<p class="py-6 opacity-70">
Aktuell werden keine Vertretungen gesucht.
</p>
</div>
</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="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{#each openSubstitutions as sub}
<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>
<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 class="card-body">
<div class="flex justify-between items-start">
<h3 class="card-title text-primary">{sub.title}</h3>
<div class="badge badge-outline font-mono">{sub.date}</div>
</div>
<p class="text-2xl font-bold my-2">
{sub.start_time}
<span class="text-base font-normal opacity-50"
>- {sub.end_time}</span
>
</p>
{#if sub.notes}
<div
class="alert alert-ghost text-sm py-2 px-3 my-2 bg-base-200/50"
>
<svg
xmlns="http://www.w3.org/2000/svg"
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>
{/each}
</div>
{/each}
</div>
{/if}
{/if}
</div>
{#if (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
{#if activeView === "schedule" && (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
<div
class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none"
>
@ -561,6 +690,7 @@
User Dashboard
</div>
</div>
<ul class="menu p-4 w-full gap-2 text-base font-medium">
<li
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
@ -568,10 +698,14 @@
Navigation
</li>
<li>
<a
class="active bg-primary/10 text-primary"
href="#"
on:click={() => closeDrawer()}
<button
class={activeView === "schedule"
? "active bg-primary/10 text-primary"
: ""}
on:click={() => {
activeView = "schedule";
closeDrawer();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -587,7 +721,38 @@
/></svg
>
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>
<button on:click={() => (showPwModal = true)}>

View file

@ -1,10 +1,34 @@
<script>
import { uploadLogo } from "../../lib/api";
import { uploadLogo, getLicenseStatus, uploadLicense } from "../../lib/api";
import { addToast } from "../../lib/stores";
import { onMount } from "svelte";
let fileInput;
let previewSrc = "/api/logo?t=" + Date.now();
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) {
const file = e.target.files[0];
@ -74,3 +98,57 @@
</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,
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");
};