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