fix: fix error user dont get updated targetWorkingHours

also perform code formatting and cleanup
This commit is contained in:
Patryk Hegenberg 2026-01-16 11:25:34 +01:00
parent 3fadb6d86d
commit 8fe3d71dde
21 changed files with 2348 additions and 1235 deletions

View file

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

View file

@ -17,7 +17,9 @@
*/ */
"sourceMap": true, "sourceMap": true,
"esModuleInterop": true, "esModuleInterop": true,
"types": ["vite/client"], "types": [
"vite/client"
],
"skipLibCheck": true, "skipLibCheck": true,
/** /**
* Typecheck JS in `.svelte` and `.js` files by default. * Typecheck JS in `.svelte` and `.js` files by default.
@ -29,5 +31,9 @@
* Use global.d.ts instead of compilerOptions.types * Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations. * to avoid limiting type declarations.
*/ */
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] "include": [
"src/**/*.d.ts",
"src/**/*.js",
"src/**/*.svelte"
]
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,23 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { auth, addToast } from '../lib/stores'; import { auth, addToast } from "../lib/stores";
import { logout, getSchedules, getMyTimeEntries, saveTimeEntriesBatch, deleteWeekEntries, changeMyPassword } from '../lib/api'; import {
import { getISOWeek, getISOYear, formatDate, getDateOfISOWeek, calculateHours } from '../lib/utils'; logout,
import ScheduleItem from './ScheduleItem.svelte'; getSchedules,
getMyTimeEntries,
saveTimeEntriesBatch,
deleteWeekEntries,
changeMyPassword,
getMyInfo,
} from "../lib/api";
import {
getISOWeek,
getISOYear,
formatDate,
getDateOfISOWeek,
calculateHours,
} from "../lib/utils";
import ScheduleItem from "./ScheduleItem.svelte";
const today = new Date(); const today = new Date();
let currentISOYear = getISOYear(today); let currentISOYear = getISOYear(today);
@ -22,34 +36,41 @@
let showPwModal = false; let showPwModal = false;
let pwData = { old: "", new1: "", new2: "" }; let pwData = { old: "", new1: "", new2: "" };
$: 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) => {
const d = getDateOfISOWeek(currentWeek, currentISOYear); const d = getDateOfISOWeek(currentWeek, currentISOYear);
d.setDate(d.getDate() + i); d.setDate(d.getDate() + i);
return { return {
name: ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'][i], name: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"][i],
date: formatDate(d), date: formatDate(d),
dayIndex: i dayIndex: i,
}; };
}); });
$: yearlyTotal = allEntries.reduce((sum, entry) => { $: yearlyTotal = allEntries.reduce((sum, entry) => {
let hours = 0; let hours = 0;
if (entry.entryType === 'lesson') hours = 1.0; if (entry.entryType === "lesson") hours = 1.0;
else hours = calculateHours(entry.startTime, entry.endTime); else hours = calculateHours(entry.startTime, entry.endTime);
return sum + hours; return sum + hours;
}, 0); }, 0);
$: userTarget = $auth.user?.yearlyWorkHours || 60; $: userTarget = $auth.user?.yearlyWorkHours || 60;
$: remaining = userTarget - yearlyTotal; $: remaining = userTarget - yearlyTotal;
$: progressPercent = userTarget > 0 ? Math.min(100, (yearlyTotal / userTarget) * 100) : 0; $: progressPercent =
$: progressClass = remaining <= 0 ? "progress-success" : (yearlyTotal >= userTarget * 0.8 ? "progress-info" : "progress-warning"); userTarget > 0 ? Math.min(100, (yearlyTotal / userTarget) * 100) : 0;
$: progressClass =
remaining <= 0
? "progress-success"
: yearlyTotal >= userTarget * 0.8
? "progress-info"
: "progress-warning";
onMount(loadData); onMount(loadData);
async function handleDeleteWeek() { async function handleDeleteWeek() {
if(!confirm("Möchten Sie wirklich alle Einträge dieser Woche löschen?")) return; if (!confirm("Möchten Sie wirklich alle Einträge dieser Woche löschen?"))
return;
processing = true; processing = true;
try { try {
@ -67,12 +88,19 @@
async function loadData() { async function loadData() {
isLoadingData = true; isLoadingData = true;
try { try {
const [schedulesData, entriesData] = await Promise.all([ const [schedulesData, entriesData, userData] = await Promise.all([
getSchedules(), getSchedules(),
getMyTimeEntries() getMyTimeEntries(),
getMyInfo(),
]); ]);
schedules = schedulesData; schedules = schedulesData;
allEntries = entriesData; allEntries = entriesData;
auth.update((current) => ({
...current,
user: userData,
}));
filterEntries(entriesData); filterEntries(entriesData);
} finally { } finally {
isLoadingData = false; isLoadingData = false;
@ -80,20 +108,28 @@
} }
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);
const entryDate = new Date(y, m - 1, d); const entryDate = new Date(y, m - 1, d);
return getISOYear(entryDate) === currentISOYear && getISOWeek(entryDate) === currentWeek; return (
getISOYear(entryDate) === currentISOYear &&
getISOWeek(entryDate) === currentWeek
);
}); });
selectedEntries = existingEntries.map(e => { selectedEntries = existingEntries
const sched = schedules.find(s => s.id === e.scheduleId); .map((e) => {
if (sched) return { scheduleId: e.scheduleId, dayOfWeek: sched.dayOfWeek }; const sched = schedules.find((s) => s.id === e.scheduleId);
if (sched)
return { scheduleId: e.scheduleId, dayOfWeek: sched.dayOfWeek };
return null; return null;
}).filter(item => item !== null); })
.filter((item) => item !== null);
} }
function toggleSelection(scheduleId, dayOfWeek) { function toggleSelection(scheduleId, dayOfWeek) {
const index = selectedEntries.findIndex(e => e.scheduleId === scheduleId && e.dayOfWeek === dayOfWeek); const index = selectedEntries.findIndex(
(e) => e.scheduleId === scheduleId && e.dayOfWeek === dayOfWeek,
);
if (index >= 0) selectedEntries.splice(index, 1); if (index >= 0) selectedEntries.splice(index, 1);
else selectedEntries.push({ scheduleId, dayOfWeek }); else selectedEntries.push({ scheduleId, dayOfWeek });
selectedEntries = selectedEntries; selectedEntries = selectedEntries;
@ -102,15 +138,15 @@
async function saveEntries() { async function saveEntries() {
processing = true; processing = true;
try { try {
const entriesToSave = selectedEntries.map(sel => { const entriesToSave = selectedEntries.map((sel) => {
const sched = schedules.find(s => s.id === sel.scheduleId); const sched = schedules.find((s) => s.id === sel.scheduleId);
const dateObj = weekDates.find(d => d.dayIndex === sel.dayOfWeek); const dateObj = weekDates.find((d) => d.dayIndex === sel.dayOfWeek);
return { return {
schedule_id: sel.scheduleId, schedule_id: sel.scheduleId,
date: dateObj.date, date: dateObj.date,
type: sched.scheduleType, type: sched.scheduleType,
start_time: sched.startTime, start_time: sched.startTime,
end_time: sched.endTime end_time: sched.endTime,
}; };
}); });
if (entriesToSave.length > 0) await saveTimeEntriesBatch(entriesToSave); if (entriesToSave.length > 0) await saveTimeEntriesBatch(entriesToSave);
@ -123,13 +159,15 @@
function changeWeek(delta) { function changeWeek(delta) {
const d = getDateOfISOWeek(currentWeek, currentISOYear); const d = getDateOfISOWeek(currentWeek, currentISOYear);
d.setDate(d.getDate() + (delta * 7)); d.setDate(d.getDate() + delta * 7);
currentWeek = getISOWeek(d); currentWeek = getISOWeek(d);
currentISOYear = getISOYear(d); currentISOYear = getISOYear(d);
loadData(); loadData();
} }
function closeDrawer() { isDrawerOpen = false; } function closeDrawer() {
isDrawerOpen = false;
}
async function handleChangePassword() { async function handleChangePassword() {
if (pwData.new1 !== pwData.new2) { if (pwData.new1 !== pwData.new2) {
@ -146,20 +184,36 @@
addToast("Passwort erfolgreich geändert!", "success"); addToast("Passwort erfolgreich geändert!", "success");
showPwModal = false; showPwModal = false;
pwData = { old: "", new1: "", new2: "" }; pwData = { old: "", new1: "", new2: "" };
} catch (e) { } catch (e) {}
}
} }
</script> </script>
<div class="drawer lg:drawer-open"> <div class="drawer lg:drawer-open">
<input id="user-drawer" type="checkbox" class="drawer-toggle" bind:checked={isDrawerOpen} /> <input
id="user-drawer"
type="checkbox"
class="drawer-toggle"
bind:checked={isDrawerOpen}
/>
<div class="drawer-content flex flex-col bg-base-200 min-h-screen pb-20"> <div class="drawer-content flex flex-col bg-base-200 min-h-screen pb-20">
<div
<div class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8"> class="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8"
>
<div class="flex-none lg:hidden"> <div class="flex-none lg:hidden">
<label for="user-drawer" class="btn btn-square btn-ghost"> <label for="user-drawer" class="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-6 h-6 stroke-current"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-6 h-6 stroke-current"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
></path></svg
>
</label> </label>
</div> </div>
@ -179,32 +233,87 @@
<div class="text-xs opacity-50">Benutzer</div> <div class="text-xs opacity-50">Benutzer</div>
</div> </div>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar placeholder"> <div
tabindex="0"
role="button"
class="btn btn-ghost btn-circle avatar placeholder"
>
<div class="bg-primary text-primary-content rounded-full w-10"> <div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-xl">{$auth.user?.username?.charAt(0).toUpperCase()}</span> <span class="text-xl"
>{$auth.user?.username?.charAt(0).toUpperCase()}</span
>
</div> </div>
</div> </div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"> <ul
<li><button on:click={logout} class="text-error font-bold">Abmelden</button></li> tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
>
<li>
<button on:click={logout} class="text-error font-bold"
>Abmelden</button
>
</li>
</ul> </ul>
</div> </div>
</div> </div>
</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"> <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"> <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}> <button
<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> class="btn btn-circle btn-ghost"
on:click={() => changeWeek(-1)}
disabled={isLoadingData}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/></svg
>
</button> </button>
<div class="text-center"> <div class="text-center">
<p class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1">Kalenderwoche</p> <p
<h2 class="text-xl sm:text-3xl font-bold">KW {currentWeek} <span class="text-base-content/30">/</span> {currentISOYear}</h2> class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1"
<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> >
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> </div>
<button class="btn btn-circle btn-ghost" on:click={() => changeWeek(1)} disabled={isLoadingData}> <button
<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> 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> </button>
</div> </div>
</div> </div>
@ -219,13 +328,21 @@
<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 class="stat-value text-lg {remaining <= 0 ? 'text-success' : 'text-warning'}">{Math.max(0, remaining).toFixed(1)}</div> <div
class="stat-value text-lg {remaining <= 0
? 'text-success'
: 'text-warning'}"
>
{Math.max(0, remaining).toFixed(1)}
</div>
</div> </div>
</div> </div>
</div> </div>
{#if isLoadingData} {#if isLoadingData}
<div class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"> <div
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 _} {#each Array(5) as _}
<div class="flex-1 space-y-4"> <div class="flex-1 space-y-4">
@ -237,33 +354,89 @@
</div> </div>
</div> </div>
<div class="lg:hidden space-y-4"> <div class="lg:hidden space-y-4">
{#each Array(5) as _} <div class="skeleton h-16 w-full rounded-lg"></div> {/each} {#each Array(5) as _}
<div class="skeleton h-16 w-full rounded-lg"></div>
{/each}
</div> </div>
{:else} {:else}
{#if hasEntriesForWeek && !weekEditMode} {#if hasEntriesForWeek && !weekEditMode}
<div role="alert" class="alert alert-success shadow-sm"> <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> <svg
<div><h3 class="font-bold">Erfasst!</h3><div class="text-xs">Stunden gespeichert.</div></div> xmlns="http://www.w3.org/2000/svg"
<button class="btn btn-sm btn-outline" on:click={() => weekEditMode = true} disabled={processing}>Bearbeiten</button> 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> </div>
{:else if weekEditMode} {:else if weekEditMode}
<div role="alert" class="alert alert-warning shadow-sm"> <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> <svg
<div><h3 class="font-bold">Bearbeitungsmodus</h3><div class="text-xs">Speichern nicht vergessen!</div></div> 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"> <div class="flex gap-2">
<button class="btn btn-sm btn-error" on:click={handleDeleteWeek} disabled={processing}>Löschen</button> <button
<button class="btn btn-sm btn-ghost" on:click={() => weekEditMode = false}>Abbrechen</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 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> <svg
<div><h3 class="font-bold">Zeiterfassung</h3><div class="text-xs">Wählen Sie Ihre Stunden.</div></div> 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> </div>
{/if} {/if}
<div class="overflow-x-auto hidden lg:block bg-base-100 rounded-2xl shadow-xl border border-base-200"> <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"> <table class="table table-fixed w-full">
<thead> <thead>
<tr> <tr>
@ -278,14 +451,23 @@
<tbody> <tbody>
<tr> <tr>
{#each weekDates as day} {#each weekDates as day}
<td class="align-top p-2 min-w-[160px] border-r border-base-200 last:border-0"> <td
class="align-top p-2 min-w-[160px] border-r border-base-200 last:border-0"
>
<div class="space-y-2"> <div class="space-y-2">
{#each schedules.filter(s => s.dayOfWeek === day.dayIndex) as schedule} {#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem <ScheduleItem
{schedule} dayOfWeek={day.dayIndex} {schedule}
isSelected={selectedEntries.some(e => e.scheduleId === schedule.id && e.dayOfWeek === day.dayIndex)} dayOfWeek={day.dayIndex}
isClickable={(!hasEntriesForWeek || weekEditMode) && !processing} isSelected={selectedEntries.some(
on:toggle={() => toggleSelection(schedule.id, day.dayIndex)} (e) =>
e.scheduleId === schedule.id &&
e.dayOfWeek === day.dayIndex,
)}
isClickable={(!hasEntriesForWeek || weekEditMode) &&
!processing}
on:toggle={() =>
toggleSelection(schedule.id, day.dayIndex)}
/> />
{/each} {/each}
</div> </div>
@ -298,20 +480,31 @@
<div class="lg:hidden space-y-4"> <div class="lg:hidden space-y-4">
{#each weekDates as day} {#each weekDates as day}
<div class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200"> <div
class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200"
>
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-lg font-medium flex justify-between items-center"> <div
class="collapse-title text-lg font-medium flex justify-between items-center"
>
<span>{day.name}</span> <span>{day.name}</span>
<span class="text-sm opacity-50 font-mono">{day.date}</span> <span class="text-sm opacity-50 font-mono">{day.date}</span>
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<div class="pt-2 space-y-2"> <div class="pt-2 space-y-2">
{#each schedules.filter(s => s.dayOfWeek === day.dayIndex) as schedule} {#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem <ScheduleItem
{schedule} dayOfWeek={day.dayIndex} {schedule}
isSelected={selectedEntries.some(e => e.scheduleId === schedule.id && e.dayOfWeek === day.dayIndex)} dayOfWeek={day.dayIndex}
isClickable={(!hasEntriesForWeek || weekEditMode) && !processing} isSelected={selectedEntries.some(
on:toggle={() => toggleSelection(schedule.id, day.dayIndex)} (e) =>
e.scheduleId === schedule.id &&
e.dayOfWeek === day.dayIndex,
)}
isClickable={(!hasEntriesForWeek || weekEditMode) &&
!processing}
on:toggle={() =>
toggleSelection(schedule.id, day.dayIndex)}
/> />
{/each} {/each}
</div> </div>
@ -320,29 +513,30 @@
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
{#if (!hasEntriesForWeek || weekEditMode) && !isLoadingData} {#if (!hasEntriesForWeek || weekEditMode) && !isLoadingData}
<div class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none"> <div
class="sticky bottom-6 z-20 px-4 md:px-8 lg:px-10 pointer-events-none"
>
<button <button
class="btn btn-primary btn-lg w-full shadow-2xl border-primary-focus transform active:scale-[0.99] transition-transform pointer-events-auto" class="btn btn-primary btn-lg w-full shadow-2xl border-primary-focus transform active:scale-[0.99] transition-transform pointer-events-auto"
disabled={selectedEntries.length === 0 || processing} disabled={selectedEntries.length === 0 || processing}
on:click={saveEntries} on:click={saveEntries}
> >
{#if processing}<span class="loading loading-spinner"></span>{/if} {#if processing}<span class="loading loading-spinner"></span>{/if}
{weekEditMode ? 'Änderungen speichern' : 'Speichern'} {weekEditMode ? "Änderungen speichern" : "Speichern"}
</button> </button>
</div> </div>
{/if} {/if}
</div> </div>
<div class="drawer-side z-40"> <div class="drawer-side z-40">
<label for="user-drawer" class="drawer-overlay"></label> <label for="user-drawer" class="drawer-overlay"></label>
<aside
<aside class="bg-base-100 w-80 h-full flex flex-col border-r border-base-300"> class="bg-base-100 w-80 h-full flex flex-col border-r border-base-300"
>
<div class="p-6 border-b border-base-200"> <div class="p-6 border-b border-base-200">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 flex items-center justify-center"> <div class="w-10 h-10 flex items-center justify-center">
@ -351,43 +545,88 @@
alt="Logo" alt="Logo"
class="w-full h-full object-contain" class="w-full h-full object-contain"
on:error={(e) => { on:error={(e) => {
e.target.style.display='none'; e.target.style.display = "none";
e.target.nextElementSibling.style.display='flex'; e.target.nextElementSibling.style.display = "flex";
}} }}
/> />
<div class="hidden w-10 h-10 rounded bg-primary text-primary-content font-bold text-xl items-center justify-center"> <div
class="hidden w-10 h-10 rounded bg-primary text-primary-content font-bold text-xl items-center justify-center"
>
Z Z
</div> </div>
</div> </div>
<div class="font-bold text-xl tracking-tight">Zeiterfassung</div> <div class="font-bold text-xl tracking-tight">Zeiterfassung</div>
</div> </div>
<div class="text-xs font-mono opacity-50 mt-1 pl-14">User Dashboard</div> <div class="text-xs font-mono opacity-50 mt-1 pl-14">
User Dashboard
</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 class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1">Navigation</li> <li
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
>
Navigation
</li>
<li> <li>
<a class="active bg-primary/10 text-primary" href="#" on:click={() => closeDrawer()}> <a
<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> class="active bg-primary/10 text-primary"
href="#"
on:click={() => closeDrawer()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/></svg
>
Stundenplan Stundenplan
</a> </a>
</li> </li>
<li> <li>
<button on:click={() => showPwModal = true}> <button on:click={() => (showPwModal = true)}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></svg> <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/></svg
>
Passwort ändern Passwort ändern
</button> </button>
</li> </li>
</ul> </ul>
<div class="mt-auto p-4 bg-base-200 m-4 rounded-xl space-y-4"> <div class="mt-auto p-4 bg-base-200 m-4 rounded-xl space-y-4">
<div class="text-xs font-bold uppercase opacity-50 tracking-wider">Jahresfortschritt</div> <div class="text-xs font-bold uppercase opacity-50 tracking-wider">
Jahresfortschritt
</div>
<div class="flex justify-between items-end"> <div class="flex justify-between items-end">
<div> <div>
<div class="text-3xl font-bold text-primary">{yearlyTotal.toFixed(1)}</div> <div class="text-3xl font-bold text-primary">
<div class="text-xs opacity-70">von {userTarget.toFixed(1)} Stunden</div> {yearlyTotal.toFixed(1)}
</div> </div>
<div class="radial-progress text-primary text-xs font-bold" style="--value:{progressPercent}; --size:3rem;"> <div class="text-xs opacity-70">
von {userTarget.toFixed(1)} Stunden
</div>
</div>
<div
class="radial-progress text-primary text-xs font-bold"
style="--value:{progressPercent}; --size:3rem;"
>
{Math.round(progressPercent)}% {Math.round(progressPercent)}%
</div> </div>
</div> </div>
@ -396,17 +635,38 @@
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="opacity-70">Verbleibend:</span> <span class="opacity-70">Verbleibend:</span>
<span class="font-bold {remaining <= 0 ? 'text-success' : 'text-warning'}"> <span
class="font-bold {remaining <= 0 ? 'text-success' : 'text-warning'}"
>
{Math.max(0, remaining).toFixed(1)} h {Math.max(0, remaining).toFixed(1)} h
</span> </span>
</div> </div>
<progress class="progress w-full h-2 {progressClass}" value={progressPercent} max="100"></progress> <progress
class="progress w-full h-2 {progressClass}"
value={progressPercent}
max="100"
></progress>
</div> </div>
<div class="p-4 border-t border-base-200"> <div class="p-4 border-t border-base-200">
<button on:click={logout} class="btn btn-ghost btn-sm w-full justify-start text-error"> <button
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg> on:click={logout}
class="btn btn-ghost btn-sm w-full justify-start text-error"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 mr-2"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/></svg
>
Abmelden Abmelden
</button> </button>
</div> </div>
@ -421,25 +681,55 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="form-control"> <div class="form-control">
<label class="label">Altes Passwort</label> <label class="label">Altes Passwort</label>
<input type="password" class="input input-bordered" bind:value={pwData.old} /> <input
type="password"
class="input input-bordered"
bind:value={pwData.old}
/>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label">Neues Passwort</label> <label class="label">Neues Passwort</label>
<input type="password" class="input input-bordered" bind:value={pwData.new1} /> <input
type="password"
class="input input-bordered"
bind:value={pwData.new1}
/>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label">Wiederholung</label> <label class="label">Wiederholung</label>
<input type="password" class="input input-bordered" bind:value={pwData.new2} /> <input
type="password"
class="input input-bordered"
bind:value={pwData.new2}
/>
</div> </div>
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button class="btn" on:click={() => showPwModal = false}>Abbrechen</button> <button class="btn" on:click={() => (showPwModal = false)}
<button class="btn btn-primary" on:click={handleChangePassword} disabled={!pwData.old || !pwData.new1}>Speichern</button> >Abbrechen</button
>
<button
class="btn btn-primary"
on:click={handleChangePassword}
disabled={!pwData.old || !pwData.new1}>Speichern</button
>
</div> </div>
</div> </div>
</dialog> </dialog>
<style> <style>
.fade-in { animation: fadeIn 0.3s ease-in-out; } .fade-in {
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style> </style>

View file

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

View file

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

View file

@ -1,6 +1,6 @@
<script> <script>
import { uploadLogo } from '../../lib/api'; import { uploadLogo } from "../../lib/api";
import { addToast } from '../../lib/stores'; import { addToast } from "../../lib/stores";
let fileInput; let fileInput;
let previewSrc = "/api/logo?t=" + Date.now(); let previewSrc = "/api/logo?t=" + Date.now();
@ -10,7 +10,7 @@
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
if (!file.type.startsWith('image/')) { if (!file.type.startsWith("image/")) {
addToast("Bitte nur Bilder hochladen", "warning"); addToast("Bitte nur Bilder hochladen", "warning");
return; return;
} }
@ -22,7 +22,7 @@
const timestamp = Date.now(); const timestamp = Date.now();
previewSrc = `/api/logo?t=${timestamp}`; previewSrc = `/api/logo?t=${timestamp}`;
window.dispatchEvent(new Event('logo-updated')); window.dispatchEvent(new Event("logo-updated"));
} catch (err) { } catch (err) {
addToast(err.message, "error"); addToast(err.message, "error");
} finally { } finally {
@ -38,13 +38,21 @@
<div class="form-control w-full"> <div class="form-control w-full">
<label class="label"> <label class="label">
<span class="label-text font-bold">Schul-Logo</span> <span class="label-text font-bold">Schul-Logo</span>
<span class="label-text-alt">Wird im Login und Dashboard angezeigt</span> <span class="label-text-alt"
>Wird im Login und Dashboard angezeigt</span
>
</label> </label>
<div class="flex items-center gap-6 mt-2"> <div class="flex items-center gap-6 mt-2">
<div class="avatar placeholder border border-base-300 rounded-lg p-1 bg-base-200"> <div
class="avatar placeholder border border-base-300 rounded-lg p-1 bg-base-200"
>
<div class="w-24 h-24 rounded-lg"> <div class="w-24 h-24 rounded-lg">
<img src={previewSrc} alt="Logo" on:error={(e) => e.target.style.display='none'} /> <img
src={previewSrc}
alt="Logo"
on:error={(e) => (e.target.style.display = "none")}
/>
</div> </div>
</div> </div>
@ -58,7 +66,7 @@
disabled={uploading} disabled={uploading}
/> />
<div class="text-xs text-base-content/50 mt-2"> <div class="text-xs text-base-content/50 mt-2">
Empfohlen: PNG mit transparentem Hintergrund.<br> Empfohlen: PNG mit transparentem Hintergrund.<br />
Max. 2MB. Max. 2MB.
</div> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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