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

View file

@ -17,7 +17,9 @@
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"types": [
"vite/client"
],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
@ -29,5 +31,9 @@
* Use global.d.ts instead of compilerOptions.types
* 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>
import { auth } from './lib/stores';
import Login from './components/Login.svelte';
import UserDashboard from './components/UserDashboard.svelte';
import AdminDashboard from './components/AdminDashboard.svelte';
import ToastNotification from './components/ToastNotification.svelte';
import { onMount } from 'svelte';
import { addToast } from './lib/stores';
import { auth } from "./lib/stores";
import Login from "./components/Login.svelte";
import UserDashboard from "./components/UserDashboard.svelte";
import AdminDashboard from "./components/AdminDashboard.svelte";
import ToastNotification from "./components/ToastNotification.svelte";
import { onMount } from "svelte";
import { addToast } from "./lib/stores";
$: user = $auth.user;
$: isAuthenticated = $auth.isAuthenticated;
onMount(() => {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const darkModeMediaQuery = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const applyTheme = (e) => {
const isDark = e.matches;
const theme = isDark ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
const theme = isDark ? "dark" : "light";
document.documentElement.setAttribute("data-theme", theme);
};
applyTheme(darkModeMediaQuery);
darkModeMediaQuery.addEventListener('change', applyTheme);
darkModeMediaQuery.addEventListener("change", applyTheme);
const handleRejection = (event) => {
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");
}
};
@ -34,12 +36,12 @@
addToast("Kritischer Anwendungsfehler. Bitte neu laden.", "error");
};
window.addEventListener('unhandledrejection', handleRejection);
window.addEventListener('error', handleError);
window.addEventListener("unhandledrejection", handleRejection);
window.addEventListener("error", handleError);
return () => {
window.removeEventListener('unhandledrejection', handleRejection);
window.removeEventListener('error', handleError);
window.removeEventListener("unhandledrejection", handleRejection);
window.removeEventListener("error", handleError);
};
});
</script>
@ -57,4 +59,3 @@
{/if}
</main>
</div>

View file

@ -1,43 +1,67 @@
<script>
import { auth } from '../lib/stores';
import { logout } from '../lib/api';
import { auth } from "../lib/stores";
import { logout } from "../lib/api";
import AdminScheduleTab from './admin/AdminScheduleTab.svelte';
import AdminUsersTab from './admin/AdminUsersTab.svelte';
import AdminTimeEntriesTab from './admin/AdminTimeEntriesTab.svelte';
import AdminSchoolYearsTab from './admin/AdminSchoolYearsTab.svelte';
import AdminSettingsTab from './admin/AdminSettingsTab.svelte';
import AdminScheduleTab from "./admin/AdminScheduleTab.svelte";
import AdminUsersTab from "./admin/AdminUsersTab.svelte";
import AdminTimeEntriesTab from "./admin/AdminTimeEntriesTab.svelte";
import AdminSchoolYearsTab from "./admin/AdminSchoolYearsTab.svelte";
import AdminSettingsTab from "./admin/AdminSettingsTab.svelte";
let activeTab = 'schedule';
let activeTab = "schedule";
const user = $auth.user;
$: pageTitle = getPageTitle(activeTab);
function getPageTitle(tab) {
switch (tab) {
case 'schedule': return 'Stundenplan Konfiguration';
case 'users': return 'Benutzerverwaltung';
case 'timeEntries': return 'Zeiteinträge & Buchungen';
case 'schoolYears': return 'Schuljahre & Perioden';
case 'settings': return 'Einstellungen';
default: return 'Admin';
case "schedule":
return "Stundenplan Konfiguration";
case "users":
return "Benutzerverwaltung";
case "timeEntries":
return "Zeiteinträge & Buchungen";
case "schoolYears":
return "Schuljahre & Perioden";
case "settings":
return "Einstellungen";
default:
return "Admin";
}
}
let isDrawerOpen = false;
function closeDrawer() { isDrawerOpen = false; }
function closeDrawer() {
isDrawerOpen = false;
}
</script>
<div class="drawer lg:drawer-open">
<input id="admin-drawer" type="checkbox" class="drawer-toggle" bind:checked={isDrawerOpen} />
<input
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="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8">
<div
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">
<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>
</div>
@ -58,38 +82,55 @@
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" 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
tabindex="0"
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>
<ul 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
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>
</div>
</div>
</div>
<div class="p-4 md:p-8 lg:p-10 fade-in">
{#if activeTab === 'schedule'}
{#if activeTab === "schedule"}
<AdminScheduleTab />
{:else if activeTab === 'users'}
{:else if activeTab === "users"}
<AdminUsersTab />
{:else if activeTab === 'timeEntries'}
{:else if activeTab === "timeEntries"}
<AdminTimeEntriesTab />
{:else if activeTab === 'schoolYears'}
{:else if activeTab === "schoolYears"}
<AdminSchoolYearsTab />
{:else if activeTab === 'settings'}
{:else if activeTab === "settings"}
<AdminSettingsTab />
{/if}
</div>
</div>
<div class="drawer-side z-40">
<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="flex items-center gap-3">
<div class="w-10 h-10 flex items-center justify-center">
@ -98,77 +139,199 @@
alt="Logo"
class="w-full h-full object-contain"
on:error={(e) => {
e.target.style.display='none';
e.target.nextElementSibling.style.display='flex';
e.target.style.display = "none";
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
</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 class="text-xs font-mono opacity-50 mt-1 pl-14">Admin Dashboard</div>
</div>
<ul class="menu p-4 w-full gap-2 text-base font-medium flex-1">
<li class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1">Verwaltung</li>
<li
class="menu-title opacity-50 uppercase text-xs font-bold tracking-wider mt-2 mb-1"
>
Verwaltung
</li>
<li>
<button
class="{activeTab === 'schedule' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'schedule'; closeDrawer(); }}
class={activeTab === "schedule"
? "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
</button>
</li>
<li>
<button
class="{activeTab === 'users' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'users'; closeDrawer(); }}
class={activeTab === "users"
? "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
</button>
</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>
<button
class="{activeTab === 'timeEntries' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'timeEntries'; closeDrawer(); }}
class={activeTab === "timeEntries"
? "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
</button>
</li>
<li>
<button
class="{activeTab === 'schoolYears' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'schoolYears'; closeDrawer(); }}
class={activeTab === "schoolYears"
? "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
</button>
</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>
<button
class="{activeTab === 'settings' ? 'active bg-primary/10 text-primary' : ''}"
on:click={() => { activeTab = 'settings'; closeDrawer(); }}
class={activeTab === "settings"
? "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
</button>
</li>
</ul>
<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">
<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>
<button
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
</button>
</div>
@ -182,7 +345,13 @@
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -1,9 +1,9 @@
<script>
import { login } from '../lib/api';
import { loading } from '../lib/stores';
import { login } from "../lib/api";
import { loading } from "../lib/stores";
let username = '';
let password = '';
let username = "";
let password = "";
let showPassword = false;
let logoSrc = "/api/logo?t=" + Date.now();
@ -16,17 +16,17 @@
<div class="hero min-h-screen bg-base-200">
<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">
<img
src={logoSrc}
alt="Schul-Logo"
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>
<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>
</div>
@ -57,30 +57,58 @@
placeholder="••••••••"
class="input input-bordered w-full pr-10"
bind:value={password}
on:keydown={(e) => e.key === 'Enter' && handleLogin()}
on:keydown={(e) => e.key === "Enter" && handleLogin()}
/>
<button
type="button"
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"
>
{#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">
<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
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>
{: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">
<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
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="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>
{/if}
</button>
</div>
<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>
</div>

View file

@ -1,5 +1,5 @@
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher } from "svelte";
export let schedule;
export let dayOfWeek;
@ -10,18 +10,22 @@
$: bgClass = isSelected
? "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";
$: borderClass = isSelected
? "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>
<div
class="card rounded-lg transition-all duration-200 {bgClass} {cursorClass} {borderClass}"
on:click={() => isClickable && dispatch('toggle')}
on:click={() => isClickable && dispatch("toggle")}
on:keydown={() => {}}
role="button"
tabindex="0"
@ -33,9 +37,12 @@
<div class="text-sm font-medium mt-1 truncate">
{schedule.title}
</div>
{#if schedule.scheduleType === 'break'}
{#if schedule.scheduleType === "break"}
<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>
{/if}
</div>

View file

@ -1,5 +1,5 @@
<script>
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher } from "svelte";
export let schedule;
export let dayOfWeek;
@ -8,24 +8,26 @@
const dispatch = createEventDispatcher();
// Style-Logik basierend auf dem Status
$: boxClass = isSelected
? "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";
// 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 = (isClickable && !isSelected) ? '2px solid transparent' : '2px solid currentColor';
$: borderStyle =
isClickable && !isSelected
? "2px solid transparent"
: "2px solid currentColor";
</script>
<div
class={boxClass}
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={() => {}}
role="button"
tabindex="0"
@ -34,6 +36,7 @@
{schedule.startTime} - {schedule.endTime}
</p>
<p class="is-size-7">
{schedule.title} {schedule.scheduleType === 'break' ? '(Pause)' : ''}
{schedule.title}
{schedule.scheduleType === "break" ? "(Pause)" : ""}
</p>
</div>

View file

@ -1,13 +1,17 @@
<script>
import { toasts, removeToast } from '../lib/stores';
import { fly } from 'svelte/transition';
import { toasts, removeToast } from "../lib/stores";
import { fly } from "svelte/transition";
function getAlertClass(type) {
switch (type) {
case 'error': return 'alert-error';
case 'warning': return 'alert-warning';
case 'success': return 'alert-success';
default: return 'alert-info';
case "error":
return "alert-error";
case "warning":
return "alert-warning";
case "success":
return "alert-success";
default:
return "alert-info";
}
}
</script>
@ -18,17 +22,53 @@
class="alert {getAlertClass(toast.type)} shadow-lg min-w-[300px]"
transition:fly={{ y: -20, duration: 300 }}
>
{#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>
{: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>
{#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
>
{: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}
<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}
<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>
{/each}
</div>

View file

@ -1,9 +1,23 @@
<script>
import { onMount } from 'svelte';
import { auth, addToast } from '../lib/stores';
import { logout, getSchedules, getMyTimeEntries, saveTimeEntriesBatch, deleteWeekEntries, changeMyPassword } from '../lib/api';
import { getISOWeek, getISOYear, formatDate, getDateOfISOWeek, calculateHours } from '../lib/utils';
import ScheduleItem from './ScheduleItem.svelte';
import { onMount } from "svelte";
import { auth, addToast } from "../lib/stores";
import {
logout,
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();
let currentISOYear = getISOYear(today);
@ -22,34 +36,41 @@
let showPwModal = false;
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) => {
const d = getDateOfISOWeek(currentWeek, currentISOYear);
d.setDate(d.getDate() + i);
return {
name: ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag'][i],
name: ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"][i],
date: formatDate(d),
dayIndex: i
dayIndex: i,
};
});
$: yearlyTotal = allEntries.reduce((sum, entry) => {
let hours = 0;
if (entry.entryType === 'lesson') hours = 1.0;
if (entry.entryType === "lesson") hours = 1.0;
else hours = calculateHours(entry.startTime, entry.endTime);
return sum + hours;
}, 0);
$: userTarget = $auth.user?.yearlyWorkHours || 60;
$: remaining = userTarget - yearlyTotal;
$: progressPercent = userTarget > 0 ? Math.min(100, (yearlyTotal / userTarget) * 100) : 0;
$: progressClass = remaining <= 0 ? "progress-success" : (yearlyTotal >= userTarget * 0.8 ? "progress-info" : "progress-warning");
$: progressPercent =
userTarget > 0 ? Math.min(100, (yearlyTotal / userTarget) * 100) : 0;
$: progressClass =
remaining <= 0
? "progress-success"
: yearlyTotal >= userTarget * 0.8
? "progress-info"
: "progress-warning";
onMount(loadData);
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;
try {
@ -67,12 +88,19 @@
async function loadData() {
isLoadingData = true;
try {
const [schedulesData, entriesData] = await Promise.all([
const [schedulesData, entriesData, userData] = await Promise.all([
getSchedules(),
getMyTimeEntries()
getMyTimeEntries(),
getMyInfo(),
]);
schedules = schedulesData;
allEntries = entriesData;
auth.update((current) => ({
...current,
user: userData,
}));
filterEntries(entriesData);
} finally {
isLoadingData = false;
@ -80,20 +108,28 @@
}
function filterEntries(entries) {
existingEntries = entries.filter(e => {
const [y, m, d] = e.date.split('-').map(Number);
existingEntries = entries.filter((e) => {
const [y, m, d] = e.date.split("-").map(Number);
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 => {
const sched = schedules.find(s => s.id === e.scheduleId);
if (sched) return { scheduleId: e.scheduleId, dayOfWeek: sched.dayOfWeek };
selectedEntries = existingEntries
.map((e) => {
const sched = schedules.find((s) => s.id === e.scheduleId);
if (sched)
return { scheduleId: e.scheduleId, dayOfWeek: sched.dayOfWeek };
return null;
}).filter(item => item !== null);
})
.filter((item) => item !== null);
}
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);
else selectedEntries.push({ scheduleId, dayOfWeek });
selectedEntries = selectedEntries;
@ -102,15 +138,15 @@
async function saveEntries() {
processing = true;
try {
const entriesToSave = selectedEntries.map(sel => {
const sched = schedules.find(s => s.id === sel.scheduleId);
const dateObj = weekDates.find(d => d.dayIndex === sel.dayOfWeek);
const entriesToSave = selectedEntries.map((sel) => {
const sched = schedules.find((s) => s.id === sel.scheduleId);
const dateObj = weekDates.find((d) => d.dayIndex === sel.dayOfWeek);
return {
schedule_id: sel.scheduleId,
date: dateObj.date,
type: sched.scheduleType,
start_time: sched.startTime,
end_time: sched.endTime
end_time: sched.endTime,
};
});
if (entriesToSave.length > 0) await saveTimeEntriesBatch(entriesToSave);
@ -123,13 +159,15 @@
function changeWeek(delta) {
const d = getDateOfISOWeek(currentWeek, currentISOYear);
d.setDate(d.getDate() + (delta * 7));
d.setDate(d.getDate() + delta * 7);
currentWeek = getISOWeek(d);
currentISOYear = getISOYear(d);
loadData();
}
function closeDrawer() { isDrawerOpen = false; }
function closeDrawer() {
isDrawerOpen = false;
}
async function handleChangePassword() {
if (pwData.new1 !== pwData.new2) {
@ -146,20 +184,36 @@
addToast("Passwort erfolgreich geändert!", "success");
showPwModal = false;
pwData = { old: "", new1: "", new2: "" };
} catch (e) {
}
} catch (e) {}
}
</script>
<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="navbar bg-base-100 shadow-sm border-b border-base-200 sticky top-0 z-30 px-4 sm:px-8">
<div
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">
<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>
</div>
@ -179,32 +233,87 @@
<div class="text-xs opacity-50">Benutzer</div>
</div>
<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">
<span class="text-xl">{$auth.user?.username?.charAt(0).toUpperCase()}</span>
<span class="text-xl"
>{$auth.user?.username?.charAt(0).toUpperCase()}</span
>
</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">
<li><button on:click={logout} class="text-error font-bold">Abmelden</button></li>
<ul
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>
</div>
</div>
</div>
<div class="p-4 md:p-8 lg:p-10 fade-in space-y-6">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-2 sm:p-4 flex-row items-center justify-between">
<button class="btn btn-circle btn-ghost" on:click={() => changeWeek(-1)} disabled={isLoadingData}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /></svg>
<button
class="btn btn-circle btn-ghost"
on:click={() => changeWeek(-1)}
disabled={isLoadingData}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/></svg
>
</button>
<div class="text-center">
<p class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1">Kalenderwoche</p>
<h2 class="text-xl sm:text-3xl font-bold">KW {currentWeek} <span class="text-base-content/30">/</span> {currentISOYear}</h2>
<p class="text-xs sm:text-sm opacity-60 mt-1 font-mono bg-base-200 inline-block px-2 py-1 rounded">{weekDates[0]?.date}{weekDates[4]?.date}</p>
<p
class="text-[10px] sm:text-xs font-bold text-primary tracking-widest uppercase mb-1"
>
Kalenderwoche
</p>
<h2 class="text-xl sm:text-3xl font-bold">
KW {currentWeek} <span class="text-base-content/30">/</span>
{currentISOYear}
</h2>
<p
class="text-xs sm:text-sm opacity-60 mt-1 font-mono bg-base-200 inline-block px-2 py-1 rounded"
>
{weekDates[0]?.date}{weekDates[4]?.date}
</p>
</div>
<button class="btn btn-circle btn-ghost" on:click={() => changeWeek(1)} disabled={isLoadingData}>
<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
class="btn btn-circle btn-ghost"
on:click={() => changeWeek(1)}
disabled={isLoadingData}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/></svg
>
</button>
</div>
</div>
@ -219,13 +328,21 @@
<div class="stats shadow-sm bg-base-100 border border-base-200 py-1">
<div class="stat p-2 text-center">
<div class="stat-desc">Offen</div>
<div class="stat-value text-lg {remaining <= 0 ? 'text-success' : 'text-warning'}">{Math.max(0, remaining).toFixed(1)}</div>
<div
class="stat-value text-lg {remaining <= 0
? 'text-success'
: 'text-warning'}"
>
{Math.max(0, remaining).toFixed(1)}
</div>
</div>
</div>
</div>
{#if isLoadingData}
<div class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200">
<div
class="hidden lg:block bg-base-100 rounded-2xl p-4 shadow-xl border border-base-200"
>
<div class="flex gap-4">
{#each Array(5) as _}
<div class="flex-1 space-y-4">
@ -237,33 +354,89 @@
</div>
</div>
<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>
{:else}
{#if hasEntriesForWeek && !weekEditMode}
<div role="alert" class="alert alert-success shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div><h3 class="font-bold">Erfasst!</h3><div class="text-xs">Stunden gespeichert.</div></div>
<button class="btn btn-sm btn-outline" on:click={() => weekEditMode = true} disabled={processing}>Bearbeiten</button>
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg
>
<div>
<h3 class="font-bold">Erfasst!</h3>
<div class="text-xs">Stunden gespeichert.</div>
</div>
<button
class="btn btn-sm btn-outline"
on:click={() => (weekEditMode = true)}
disabled={processing}>Bearbeiten</button
>
</div>
{:else if weekEditMode}
<div role="alert" class="alert alert-warning shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<div><h3 class="font-bold">Bearbeitungsmodus</h3><div class="text-xs">Speichern nicht vergessen!</div></div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/></svg
>
<div>
<h3 class="font-bold">Bearbeitungsmodus</h3>
<div class="text-xs">Speichern nicht vergessen!</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-error" on:click={handleDeleteWeek} disabled={processing}>Löschen</button>
<button class="btn btn-sm btn-ghost" on:click={() => weekEditMode = false}>Abbrechen</button>
<button
class="btn btn-sm btn-error"
on:click={handleDeleteWeek}
disabled={processing}>Löschen</button
>
<button
class="btn btn-sm btn-ghost"
on:click={() => (weekEditMode = false)}>Abbrechen</button
>
</div>
</div>
{:else}
<div role="alert" class="alert alert-info shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div><h3 class="font-bold">Zeiterfassung</h3><div class="text-xs">Wählen Sie Ihre Stunden.</div></div>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path></svg
>
<div>
<h3 class="font-bold">Zeiterfassung</h3>
<div class="text-xs">Wählen Sie Ihre Stunden.</div>
</div>
</div>
{/if}
<div class="overflow-x-auto hidden lg:block bg-base-100 rounded-2xl shadow-xl border border-base-200">
<div
class="overflow-x-auto hidden lg:block bg-base-100 rounded-2xl shadow-xl border border-base-200"
>
<table class="table table-fixed w-full">
<thead>
<tr>
@ -278,14 +451,23 @@
<tbody>
<tr>
{#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">
{#each schedules.filter(s => s.dayOfWeek === day.dayIndex) as schedule}
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem
{schedule} dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(e => e.scheduleId === schedule.id && e.dayOfWeek === day.dayIndex)}
isClickable={(!hasEntriesForWeek || weekEditMode) && !processing}
on:toggle={() => toggleSelection(schedule.id, day.dayIndex)}
{schedule}
dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(
(e) =>
e.scheduleId === schedule.id &&
e.dayOfWeek === day.dayIndex,
)}
isClickable={(!hasEntriesForWeek || weekEditMode) &&
!processing}
on:toggle={() =>
toggleSelection(schedule.id, day.dayIndex)}
/>
{/each}
</div>
@ -298,20 +480,31 @@
<div class="lg:hidden space-y-4">
{#each weekDates as day}
<div class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200">
<div
class="collapse collapse-arrow bg-base-100 shadow-md border border-base-200"
>
<input type="checkbox" />
<div class="collapse-title text-lg font-medium flex justify-between items-center">
<div
class="collapse-title text-lg font-medium flex justify-between items-center"
>
<span>{day.name}</span>
<span class="text-sm opacity-50 font-mono">{day.date}</span>
</div>
<div class="collapse-content">
<div class="pt-2 space-y-2">
{#each schedules.filter(s => s.dayOfWeek === day.dayIndex) as schedule}
{#each schedules.filter((s) => s.dayOfWeek === day.dayIndex) as schedule}
<ScheduleItem
{schedule} dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(e => e.scheduleId === schedule.id && e.dayOfWeek === day.dayIndex)}
isClickable={(!hasEntriesForWeek || weekEditMode) && !processing}
on:toggle={() => toggleSelection(schedule.id, day.dayIndex)}
{schedule}
dayOfWeek={day.dayIndex}
isSelected={selectedEntries.some(
(e) =>
e.scheduleId === schedule.id &&
e.dayOfWeek === day.dayIndex,
)}
isClickable={(!hasEntriesForWeek || weekEditMode) &&
!processing}
on:toggle={() =>
toggleSelection(schedule.id, day.dayIndex)}
/>
{/each}
</div>
@ -320,29 +513,30 @@
{/each}
</div>
{/if}
</div>
{#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
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}
on:click={saveEntries}
>
{#if processing}<span class="loading loading-spinner"></span>{/if}
{weekEditMode ? 'Änderungen speichern' : 'Speichern'}
{weekEditMode ? "Änderungen speichern" : "Speichern"}
</button>
</div>
{/if}
</div>
<div class="drawer-side z-40">
<label for="user-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="flex items-center gap-3">
<div class="w-10 h-10 flex items-center justify-center">
@ -351,43 +545,88 @@
alt="Logo"
class="w-full h-full object-contain"
on:error={(e) => {
e.target.style.display='none';
e.target.nextElementSibling.style.display='flex';
e.target.style.display = "none";
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
</div>
</div>
<div class="font-bold text-xl tracking-tight">Zeiterfassung</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>
<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>
<a 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>
<a
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
</a>
</li>
<li>
<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>
<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
>
Passwort ändern
</button>
</li>
</ul>
<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>
<div class="text-3xl font-bold text-primary">{yearlyTotal.toFixed(1)}</div>
<div class="text-xs opacity-70">von {userTarget.toFixed(1)} Stunden</div>
<div class="text-3xl font-bold text-primary">
{yearlyTotal.toFixed(1)}
</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)}%
</div>
</div>
@ -396,17 +635,38 @@
<div class="flex justify-between text-sm">
<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
</span>
</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 class="p-4 border-t border-base-200">
<button 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>
<button
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
</button>
</div>
@ -421,25 +681,55 @@
<div class="space-y-4">
<div class="form-control">
<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 class="form-control">
<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 class="form-control">
<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 class="modal-action">
<button class="btn" on:click={() => showPwModal = false}>Abbrechen</button>
<button class="btn btn-primary" on:click={handleChangePassword} disabled={!pwData.old || !pwData.new1}>Speichern</button>
<button class="btn" on:click={() => (showPwModal = false)}
>Abbrechen</button
>
<button
class="btn btn-primary"
on:click={handleChangePassword}
disabled={!pwData.old || !pwData.new1}>Speichern</button
>
</div>
</div>
</dialog>
<style>
.fade-in { animation: fadeIn 0.3s ease-in-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -1,7 +1,11 @@
<script>
import { onMount } from 'svelte';
import { getSchedules, createSchedule, deleteSchedule } from '../../lib/api';
import { loading, addToast } from '../../lib/stores';
import { onMount } from "svelte";
import {
getSchedules,
createSchedule,
deleteSchedule,
} from "../../lib/api";
import { loading, addToast } from "../../lib/stores";
let schedules = [];
let fileInput;
@ -11,10 +15,16 @@
startTime: "",
endTime: "",
scheduleType: "lesson",
title: ""
title: "",
};
const dayNames = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];
const dayNames = [
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
];
let showCopyModal = false;
let copySourceDay = "";
@ -31,13 +41,17 @@
function isValidTimeRange(start, end) {
if (!start || !end) return false;
const [h1, m1] = start.split(':').map(Number);
const [h2, m2] = end.split(':').map(Number);
return (h2 * 60 + m2) > (h1 * 60 + m1);
const [h1, m1] = start.split(":").map(Number);
const [h2, m2] = end.split(":").map(Number);
return h2 * 60 + m2 > h1 * 60 + m1;
}
async function handleCreate() {
if (newSchedule.dayOfWeek === "" || !newSchedule.startTime || !newSchedule.endTime) {
if (
newSchedule.dayOfWeek === "" ||
!newSchedule.startTime ||
!newSchedule.endTime
) {
addToast("Bitte alle Felder ausfüllen", "warning");
return;
}
@ -48,14 +62,20 @@
try {
await createSchedule(newSchedule);
newSchedule = { dayOfWeek: "", startTime: "", endTime: "", scheduleType: "lesson", title: "" };
newSchedule = {
dayOfWeek: "",
startTime: "",
endTime: "",
scheduleType: "lesson",
title: "",
};
await loadSchedules();
addToast("Eintrag erstellt", "success");
} catch (e) {}
}
async function handleDelete(id) {
if(confirm('Wirklich löschen?')) {
if (confirm("Wirklich löschen?")) {
await deleteSchedule(id);
await loadSchedules();
addToast("Gelöscht", "success");
@ -64,17 +84,24 @@
function handleExport() {
if (schedules.length === 0) return addToast("Keine Daten", "warning");
const exportData = schedules.map(s => ({ ...s, id: undefined }));
const dataStr = "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();
const exportData = schedules.map((s) => ({ ...s, id: undefined }));
const dataStr =
"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) {
const file = event.target.files[0];
if (!file) return;
event.target.value = '';
event.target.value = "";
if (!confirm("Import starten? Duplikate möglich.")) return;
const reader = new FileReader();
@ -83,105 +110,192 @@
const importedData = JSON.parse(e.target.result);
importProcessing = true;
addToast(`Importiere ${importedData.length}...`, "info");
let count = 0; let errors = 0;
let count = 0;
let errors = 0;
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({
dayOfWeek: String(item.dayOfWeek),
startTime: item.startTime,
endTime: item.endTime,
scheduleType: item.scheduleType || 'lesson',
title: item.title || ''
scheduleType: item.scheduleType || "lesson",
title: item.title || "",
});
count++;
} else errors++;
}
await loadSchedules();
errors > 0 ? addToast(`${count} importiert, ${errors} ungültig`, "warning") : addToast("Import erfolgreich", "success");
} catch (err) { addToast(err.message, "error"); }
finally { importProcessing = false; }
errors > 0
? addToast(
`${count} importiert, ${errors} ungültig`,
"warning",
)
: addToast("Import erfolgreich", "success");
} catch (err) {
addToast(err.message, "error");
} finally {
importProcessing = false;
}
};
reader.readAsText(file);
}
function toggleTargetDay(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() {
if (copySourceDay === "" || copyTargetDays.length === 0) return;
copyProcessing = true;
try {
const sourceEntries = schedules.filter(s => String(s.dayOfWeek) === copySourceDay);
if (sourceEntries.length === 0) throw new Error("Keine Quelleinträge");
const sourceEntries = schedules.filter(
(s) => String(s.dayOfWeek) === copySourceDay,
);
if (sourceEntries.length === 0)
throw new Error("Keine Quelleinträge");
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 targetDay of copyTargetDays) {
for (const entry of sourceEntries) {
if (isValidTimeRange(entry.startTime, entry.endTime)) {
await createSchedule({ ...entry, dayOfWeek: targetDay, id: undefined });
await createSchedule({
...entry,
dayOfWeek: targetDay,
id: undefined,
});
}
}
}
addToast("Kopieren erfolgreich", "success");
showCopyModal = false; copyTargetDays = []; copySourceDay = ""; await loadSchedules();
} catch (e) { addToast(e.message, "error"); }
finally { copyProcessing = false; }
showCopyModal = false;
copyTargetDays = [];
copySourceDay = "";
await loadSchedules();
} catch (e) {
addToast(e.message, "error");
} finally {
copyProcessing = false;
}
}
</script>
<div class="flex justify-between items-center mb-6">
<div class="join">
<input type="file" accept=".json" 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>
<input
type="file"
accept=".json"
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 class="card bg-base-100 shadow-xl mb-8">
<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="form-control w-full">
<label class="label"><span class="label-text font-bold">Wochentag</span></label>
<select class="select select-bordered w-full" bind:value={newSchedule.dayOfWeek}>
<label class="label"
><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>
{#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>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Startzeit</span></label>
<input type="time" class="input input-bordered w-full" bind:value={newSchedule.startTime} />
<label class="label"
><span class="label-text font-bold">Startzeit</span></label
>
<input
type="time"
class="input input-bordered w-full"
bind:value={newSchedule.startTime}
/>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Endzeit</span></label>
<input type="time" class="input input-bordered w-full" bind:value={newSchedule.endTime} />
<label class="label"
><span class="label-text font-bold">Endzeit</span></label
>
<input
type="time"
class="input input-bordered w-full"
bind:value={newSchedule.endTime}
/>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Typ</span></label>
<select class="select select-bordered w-full" bind:value={newSchedule.scheduleType}>
<label class="label"
><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="break">Pause</option>
</select>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label"><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} />
<label class="label"
><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 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}
Hinzufügen
</button>
@ -189,17 +303,39 @@
</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">
<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>
{#each schedules as s (s.id)}
<tr>
<td class="font-bold">{dayNames[s.dayOfWeek]}</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><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>
{/each}
</tbody>
@ -212,23 +348,38 @@
<div class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text">Quelle</span></label>
<select class="select select-bordered w-full" bind:value={copySourceDay}>
<label class="label"
><span class="label-text">Quelle</span></label
>
<select
class="select select-bordered w-full"
bind:value={copySourceDay}
>
<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>
</div>
<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">
{#each dayNames as day, i}
{#if String(i) !== copySourceDay}
<label 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"
<label
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))}
on:change={() => toggleTargetDay(i)} />
<span class="label-text font-medium">{day}</span>
on:change={() => toggleTargetDay(i)}
/>
<span class="label-text font-medium">{day}</span
>
</label>
{/if}
{/each}
@ -237,16 +388,32 @@
<div class="form-control bg-base-200 p-3 rounded-lg mt-4">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" class="toggle toggle-error" bind:checked={deleteExisting} />
<span class="label-text">Vorhandene Einträge am Ziel vorher löschen</span>
<input
type="checkbox"
class="toggle toggle-error"
bind:checked={deleteExisting}
/>
<span class="label-text"
>Vorhandene Einträge am Ziel vorher löschen</span
>
</label>
</div>
</div>
<div class="modal-action">
<button class="btn" 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
class="btn"
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>
</div>
</div>

View file

@ -1,7 +1,13 @@
<script>
import { onMount } from 'svelte';
import { getSchoolYears, getActiveSchoolYear, createSchoolYear, activateSchoolYear, deleteSchoolYear } from '../../lib/api';
import { loading } from '../../lib/stores';
import { onMount } from "svelte";
import {
getSchoolYears,
getActiveSchoolYear,
createSchoolYear,
activateSchoolYear,
deleteSchoolYear,
} from "../../lib/api";
import { loading } from "../../lib/stores";
let schoolYears = [];
let activeSchoolYear = null;
@ -10,7 +16,10 @@
onMount(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;
activeSchoolYear = active;
}
@ -20,16 +29,28 @@
newYear = { name: "", startDate: "", endDate: "" };
await loadData();
}
async function handleActivate(id) { await activateSchoolYear(id); await loadData(); }
async function handleDelete(id) { if(confirm('Löschen?')) { await deleteSchoolYear(id); await loadData(); } }
async function handleActivate(id) {
await activateSchoolYear(id);
await loadData();
}
async function handleDelete(id) {
if (confirm("Löschen?")) {
await deleteSchoolYear(id);
await loadData();
}
}
</script>
{#if activeSchoolYear}
<div class="alert alert-info shadow-lg mb-6">
<i class="fas fa-calendar-check"></i>
<div>
<h3 class="font-bold">Aktives Schuljahr: {activeSchoolYear.name}</h3>
<div class="text-xs">{activeSchoolYear.startDate} bis {activeSchoolYear.endDate}</div>
<h3 class="font-bold">
Aktives Schuljahr: {activeSchoolYear.name}
</h3>
<div class="text-xs">
{activeSchoolYear.startDate} bis {activeSchoolYear.endDate}
</div>
</div>
</div>
{:else}
@ -43,23 +64,46 @@
<div class="card-body grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div class="form-control w-full">
<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 class="form-control w-full">
<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 class="form-control w-full">
<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>
<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 class="overflow-x-auto bg-base-100 rounded-lg shadow-xl">
<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>
{#each schoolYears as sy}
<tr>
@ -75,9 +119,16 @@
</td>
<td>
{#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}
<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>
</tr>
{/each}

View file

@ -1,6 +1,6 @@
<script>
import { uploadLogo } from '../../lib/api';
import { addToast } from '../../lib/stores';
import { uploadLogo } from "../../lib/api";
import { addToast } from "../../lib/stores";
let fileInput;
let previewSrc = "/api/logo?t=" + Date.now();
@ -10,7 +10,7 @@
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
if (!file.type.startsWith("image/")) {
addToast("Bitte nur Bilder hochladen", "warning");
return;
}
@ -22,7 +22,7 @@
const timestamp = Date.now();
previewSrc = `/api/logo?t=${timestamp}`;
window.dispatchEvent(new Event('logo-updated'));
window.dispatchEvent(new Event("logo-updated"));
} catch (err) {
addToast(err.message, "error");
} finally {
@ -38,13 +38,21 @@
<div class="form-control w-full">
<label class="label">
<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>
<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">
<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>
@ -58,7 +66,7 @@
disabled={uploading}
/>
<div class="text-xs text-base-content/50 mt-2">
Empfohlen: PNG mit transparentem Hintergrund.<br>
Empfohlen: PNG mit transparentem Hintergrund.<br />
Max. 2MB.
</div>
</div>

View file

@ -1,30 +1,58 @@
<script>
import { onMount } from 'svelte';
import { getAllTimeEntries, getUsers, createAdminTimeEntry, updateTimeEntry, deleteTimeEntry, getYearlySummary, downloadYearlySummaryPDF } from '../../lib/api';
import { calculateHours } from '../../lib/utils';
import { loading, addToast } from '../../lib/stores';
import { onMount } from "svelte";
import {
getAllTimeEntries,
getUsers,
createAdminTimeEntry,
updateTimeEntry,
deleteTimeEntry,
getYearlySummary,
downloadYearlySummaryPDF,
} from "../../lib/api";
import { calculateHours } from "../../lib/utils";
import { loading, addToast } from "../../lib/stores";
let timeEntries = [];
let users = [];
let yearlySummary = [];
let manualEntry = { selectedUserId: "", date: "", hours: "", type: "manual" };
let manualEntry = {
selectedUserId: "",
date: "",
hours: "",
type: "manual",
};
let editingEntryId = null;
let editForm = {};
onMount(async () => {
await Promise.all([loadEntries(), loadUsers(), loadSummary()]);
});
async function loadEntries() { timeEntries = await getAllTimeEntries(); }
async function loadUsers() { users = await getUsers(); }
async function loadSummary() { yearlySummary = await getYearlySummary(); }
async function loadEntries() {
timeEntries = await getAllTimeEntries();
}
async function loadUsers() {
users = await getUsers();
}
async function loadSummary() {
yearlySummary = await getYearlySummary();
}
async function handleManualEntry() {
if(!manualEntry.selectedUserId || !manualEntry.date || !manualEntry.hours) {
addToast("Bitte alle Felder ausfüllen", "warning"); return;
if (
!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 = "";
await loadEntries(); await loadSummary();
await loadEntries();
await loadSummary();
addToast("Gebucht", "success");
}
@ -32,40 +60,85 @@
try {
const blob = await downloadYearlySummaryPDF();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `Jahresuebersicht-${new Date().getFullYear()}.pdf`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
} catch (e) { addToast("PDF Fehler", "error"); }
const a = document.createElement("a");
a.href = url;
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 }; }
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(); } }
function startEdit(entry) {
editingEntryId = entry.id;
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>
<div 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}>
<div
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
</button>
</div>
<div class="card bg-base-100 shadow-xl mb-8 border border-base-200">
<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">
<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>
{#each yearlySummary as s}
<tr>
<td class="font-bold">{s.username}</td>
<td class="text-right">{s.yearlyTarget}</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>
{#if s.remainingYearly > 0}
<span class="badge badge-warning badge-sm">Offen</span>
<span class="badge badge-warning badge-sm"
>Offen</span
>
{:else}
<span class="badge badge-success badge-sm">Erfüllt</span>
<span class="badge badge-success badge-sm"
>Erfüllt</span
>
{/if}
</td>
</tr>
@ -78,47 +151,101 @@
<div class="card bg-base-200 shadow-lg mb-8">
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-sm uppercase opacity-70">Manuelle Korrektur / Eintragung</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end">
<h3 class="card-title text-sm uppercase opacity-70">
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">
<label class="label"><span class="label-text">Mitarbeiter</span></label>
<select class="select select-bordered w-full" bind:value={manualEntry.selectedUserId}>
<label class="label"
><span class="label-text">Mitarbeiter</span></label
>
<select
class="select select-bordered w-full"
bind:value={manualEntry.selectedUserId}
>
<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>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Datum</span></label>
<input type="date" class="input input-bordered w-full" bind:value={manualEntry.date}>
<label class="label"
><span class="label-text">Datum</span></label
>
<input
type="date"
class="input input-bordered w-full"
bind:value={manualEntry.date}
/>
</div>
<div class="form-control">
<label class="label"><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">
<label class="label"
><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>
<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
</button>
</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">
<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>
{#each timeEntries as entry (entry.id)}
{#if editingEntryId === entry.id}
<tr class="bg-base-200">
<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>
<div class="flex flex-col gap-1">
<input 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}>
<input
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>
</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="break">Pause</option>
<option value="manual">Man.</option>
@ -127,8 +254,16 @@
<td>-</td>
<td>
<div class="join">
<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>
<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>
</td>
</tr>
@ -138,15 +273,35 @@
<td>{entry.date}</td>
<td>{entry.startTime} - {entry.endTime}</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}
</span>
</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>
<div class="join">
<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>
<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>
</td>
</tr>

View file

@ -1,7 +1,13 @@
<script>
import { onMount } from 'svelte';
import { getUsers, createUser, deleteUser, updateUserWorkHours, resetUserPassword } from '../../lib/api';
import { loading, addToast } from '../../lib/stores';
import { onMount } from "svelte";
import {
getUsers,
createUser,
deleteUser,
updateUserWorkHours,
resetUserPassword,
} from "../../lib/api";
import { loading, addToast } from "../../lib/stores";
let users = [];
let newUser = { username: "", password: "", isAdmin: false };
@ -10,11 +16,12 @@
let resetPasswordUserId = null;
let resetPasswordNew = "";
onMount(async () => users = await getUsers());
onMount(async () => (users = await getUsers()));
async function handleCreate() {
if (!newUser.username || !newUser.password) {
addToast("Benutzername und Passwort pflicht", "warning"); return;
addToast("Benutzername und Passwort pflicht", "warning");
return;
}
await createUser(newUser);
newUser = { username: "", password: "", isAdmin: false };
@ -23,59 +30,100 @@
}
async function handleDelete(id) {
if(confirm('Löschen?')) {
await deleteUser(id); users = await getUsers(); addToast("Gelöscht", "success");
if (confirm("Löschen?")) {
await deleteUser(id);
users = await getUsers();
addToast("Gelöscht", "success");
}
}
function startEditHours(user) {
editingUserId = user.id; editingWorkHours = String(user.yearlyWorkHours); resetPasswordUserId = null;
editingUserId = user.id;
editingWorkHours = String(user.yearlyWorkHours);
resetPasswordUserId = null;
}
async function saveWorkHours() {
if (editingUserId) {
await updateUserWorkHours(editingUserId, editingWorkHours);
editingUserId = null; users = await getUsers(); addToast("Gespeichert", "success");
editingUserId = null;
users = await getUsers();
addToast("Gespeichert", "success");
}
}
function startResetPassword(user) {
resetPasswordUserId = user.id; resetPasswordNew = ""; editingUserId = null;
resetPasswordUserId = user.id;
resetPasswordNew = "";
editingUserId = null;
}
async function savePassword() {
if (resetPasswordUserId && resetPasswordNew) {
await resetUserPassword(resetPasswordUserId, resetPasswordNew);
resetPasswordUserId = null; addToast("Passwort geändert", "success");
resetPasswordUserId = null;
addToast("Passwort geändert", "success");
}
}
</script>
<div class="card bg-base-100 shadow-xl mb-8 border border-base-200">
<div class="card-body p-4 sm:p-6">
<h3 class="card-title text-sm opacity-60 uppercase mb-2">Neuen Benutzer anlegen</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end">
<h3 class="card-title text-sm opacity-60 uppercase mb-2">
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">
<label class="label"><span class="label-text">Benutzername</span></label>
<input type="text" class="input input-bordered w-full" bind:value={newUser.username} />
<label class="label"
><span class="label-text">Benutzername</span></label
>
<input
type="text"
class="input input-bordered w-full"
bind:value={newUser.username}
/>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text">Passwort</span></label>
<input type="password" class="input input-bordered w-full" bind:value={newUser.password} />
<label class="label"
><span class="label-text">Passwort</span></label
>
<input
type="password"
class="input input-bordered w-full"
bind:value={newUser.password}
/>
</div>
<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>
<input type="checkbox" class="checkbox" bind:checked={newUser.isAdmin} />
<input
type="checkbox"
class="checkbox"
bind:checked={newUser.isAdmin}
/>
</label>
</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 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">
<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>
<tbody>
{#each users as user (user.id)}
@ -83,16 +131,32 @@
<td class="opacity-50 text-xs">{user.id}</td>
<td class="font-bold">{user.username}</td>
<td>
{#if user.isAdmin}<span class="badge badge-error badge-sm">Admin</span>
{:else}<span class="badge badge-ghost badge-sm">User</span>{/if}
{#if user.isAdmin}<span
class="badge badge-error badge-sm">Admin</span
>
{:else}<span class="badge badge-ghost badge-sm"
>User</span
>{/if}
</td>
<td>
{#if editingUserId === user.id}
<div class="join">
<input class="input input-sm input-bordered join-item w-16" 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>
<input
class="input input-sm input-bordered join-item w-16"
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>
{:else}
{user.yearlyWorkHours} h
@ -102,14 +166,39 @@
<td>
{#if resetPasswordUserId === user.id}
<div class="join">
<input class="input input-sm input-bordered join-item w-24" 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>
<input
class="input input-sm input-bordered join-item w-24"
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>
{:else if user.id !== 1} <div class="join">
<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>
{:else if user.id !== 1}
<div class="join">
<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>
{/if}
</td>

View file

@ -1,19 +1,23 @@
import { get } from 'svelte/store';
import { auth, addToast, loading } from './stores';
import { get } from "svelte/store";
import { auth, addToast, loading } from "./stores";
const BASE_URL = '/api';
const BASE_URL = "/api";
function parseJwt(token) {
if (!token) return {};
try {
const base64Url = token.split('.')[1];
const base64Url = token.split(".")[1];
if (!base64Url) return {};
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
let base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padding = base64.length % 4;
if (padding) base64 += '='.repeat(4 - padding);
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''));
if (padding) base64 += "=".repeat(4 - padding);
const jsonPayload = decodeURIComponent(
window
.atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join(""),
);
return JSON.parse(jsonPayload);
} catch (e) {
console.error("JWT Parse Error:", e);
@ -23,43 +27,58 @@ function parseJwt(token) {
function mapScheduleFromApi(s) {
return {
id: s.id, dayOfWeek: s.day_of_week, startTime: s.start_time,
endTime: s.end_time, scheduleType: s.type, title: s.title
id: s.id,
dayOfWeek: s.day_of_week,
startTime: s.start_time,
endTime: s.end_time,
scheduleType: s.type,
title: s.title,
};
}
function mapScheduleToApi(s) {
return {
day_of_week: parseInt(s.dayOfWeek), start_time: s.startTime,
end_time: s.endTime, type: s.scheduleType, title: s.title
day_of_week: parseInt(s.dayOfWeek),
start_time: s.startTime,
end_time: s.endTime,
type: s.scheduleType,
title: s.title,
};
}
function mapTimeEntryFromApi(e) {
return {
id: e.id, userId: e.user_id, scheduleId: e.schedule_id, date: e.date,
entryType: e.type || e.entry_type, username: e.username,
startTime: e.start_time, endTime: e.end_time
id: e.id,
userId: e.user_id,
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) {
return {
...u,
yearlyWorkHours: u.yearly_hours || u.yearly_work_hours || u.yearlyWorkHours || 0,
isAdmin: !!(u.isAdmin || u.is_admin)
yearlyWorkHours:
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);
const token = get(auth).token;
const headers = {};
if (!isBlob) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
if (!isBlob) headers["Content-Type"] = "application/json";
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
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);
@ -67,10 +86,13 @@ async function request(endpoint, method = 'GET', body = null, isBlob = false) {
if (!res.ok) {
if (res.status === 401) {
if (get(auth).isAuthenticated) {
addToast("Ihre Sitzung ist abgelaufen. Bitte neu anmelden.", "warning");
addToast(
"Ihre Sitzung ist abgelaufen. Bitte neu anmelden.",
"warning",
);
logout();
}
throw new Error('Sitzung abgelaufen');
throw new Error("Sitzung abgelaufen");
}
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();
const text = await res.text();
return text ? JSON.parse(text) : null;
} catch (error) {
loading.set(false);
if (error.message === 'Sitzung abgelaufen') {
if (error.message === "Sitzung abgelaufen") {
throw error;
}
if (error.name === 'TypeError' && error.message.includes('fetch')) {
addToast("Verbindung zum Server fehlgeschlagen. Sind Sie online?", "error");
if (error.name === "TypeError" && error.message.includes("fetch")) {
addToast(
"Verbindung zum Server fehlgeschlagen. Sind Sie online?",
"error",
);
throw new Error("Verbindungsfehler");
}
if (endpoint !== '/login') {
addToast(error.message || "Unbekannter Fehler", 'error');
if (endpoint !== "/login") {
addToast(error.message || "Unbekannter Fehler", "error");
}
throw error;
@ -110,20 +134,27 @@ async function request(endpoint, method = 'GET', body = null, isBlob = false) {
export const login = async (username, password) => {
try {
const data = await request('/login', 'POST', { username, password });
const data = await request("/login", "POST", { username, password });
const jwtData = parseJwt(data.token);
const userObj = {
username: data.username,
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");
return true;
} catch (e) {
addToast("Anmeldung fehlgeschlagen. Prüfen Sie Benutzername und Passwort.", "error");
addToast(
"Anmeldung fehlgeschlagen. Prüfen Sie Benutzername und Passwort.",
"error",
);
return false;
}
};
@ -132,90 +163,144 @@ export const logout = () => {
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 () => {
const data = await request('/schedules');
const data = await request("/schedules");
return data.map(mapScheduleFromApi);
};
export const createSchedule = (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 () => {
const data = await request('/my-time-entries');
const data = await request("/my-time-entries");
return data.map(mapTimeEntryFromApi);
};
export const saveTimeEntriesBatch = (entries) => request('/time-entries/batch', 'POST', { entries });
export const deleteWeekEntries = (year, week) => request(`/my-time-entries/week?year=${year}&week=${week}`, 'DELETE');
export const saveTimeEntriesBatch = (entries) =>
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 () => {
const data = await request('/admin/users/list');
const data = await request("/admin/users/list");
return data.map(mapUserFromApi);
};
export const createUser = (u) => 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 createUser = (u) =>
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 resetUserPassword = (id, new_password) => request(`/admin/users/${id}/reset-password`, 'PUT', { new_password });
export const updateUserWorkHours = (id, hours) =>
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 () => {
const data = await request('/admin/time-entries');
const data = await request("/admin/time-entries");
return data.map(mapTimeEntryFromApi);
};
export const updateTimeEntry = (id, entry) => {
const payload = { date: entry.date, start_time: entry.startTime, end_time: entry.endTime, type: entry.entryType };
return request(`/admin/time-entries/${id}`, 'PUT', payload);
const 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', {
user_id: entry.selectedUserId, date: entry.date, hours: parseFloat(entry.hours), type: 'manual'
export const createAdminTimeEntry = (entry) =>
request("/admin/time-entry", "POST", {
user_id: entry.selectedUserId,
date: entry.date,
hours: parseFloat(entry.hours),
type: "manual",
});
export const getYearlySummary = async () => {
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 }));
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,
}));
};
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 () => {
const data = await request('/admin/school-years');
return data.map(sy => ({ ...sy, startDate: sy.start_date, endDate: sy.end_date, isActive: sy.is_active }));
const data = await request("/admin/school-years");
return data.map((sy) => ({
...sy,
startDate: sy.start_date,
endDate: sy.end_date,
isActive: sy.is_active,
}));
};
export const getActiveSchoolYear = async () => {
const sy = await request('/school-year/active');
const sy = await request("/school-year/active");
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) => {
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', {
method: 'POST',
const res = await fetch("/api/admin/settings/logo", {
method: "POST",
headers: {
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`,
},
body: formData
body: formData,
});
if (!res.ok) throw new Error("Upload fehlgeschlagen");
return true;
};
export const createSchoolYear = (sy) => request('/admin/school-years', 'POST', { name: sy.name, 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 });
export const createSchoolYear = (sy) =>
request("/admin/school-years", "POST", {
name: sy.name,
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) {
if (!jsonString || jsonString === 'undefined' || jsonString === 'null') return null;
try { return JSON.parse(jsonString); } catch (e) { return null; }
if (!jsonString || jsonString === "undefined" || jsonString === "null")
return null;
try {
return JSON.parse(jsonString);
} catch (e) {
return null;
}
}
function decodeJwt(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(c =>
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
).join(''));
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
window
.atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join(""),
);
return JSON.parse(jsonPayload);
} catch (e) { return null; }
} catch (e) {
return null;
}
}
const normalizeUser = (u) => {
@ -21,8 +32,8 @@ const normalizeUser = (u) => {
return { ...u, isAdmin: !!(u.isAdmin || u.is_admin) };
};
const storedToken = localStorage.getItem('token');
let initialUser = normalizeUser(safeParse(localStorage.getItem('user')));
const storedToken = localStorage.getItem("token");
let initialUser = normalizeUser(safeParse(localStorage.getItem("user")));
let initialAuth = false;
if (storedToken) {
@ -31,8 +42,8 @@ if (storedToken) {
if (decoded && decoded.exp && decoded.exp < currentTime) {
console.warn("Token im Storage ist abgelaufen. Auto-Logout.");
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem("token");
localStorage.removeItem("user");
initialUser = null;
} else {
initialAuth = !!initialUser;
@ -42,16 +53,16 @@ if (storedToken) {
export const auth = writable({
token: initialAuth ? storedToken : null,
user: initialUser,
isAuthenticated: initialAuth
isAuthenticated: initialAuth,
});
auth.subscribe(value => {
auth.subscribe((value) => {
if (value.token && value.user) {
localStorage.setItem('token', value.token);
localStorage.setItem('user', JSON.stringify(value.user));
localStorage.setItem("token", value.token);
localStorage.setItem("user", JSON.stringify(value.user));
} else {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem("token");
localStorage.removeItem("user");
}
});
@ -59,13 +70,13 @@ export const loading = writable(false);
export const toasts = writable([]);
export function addToast(message, type = 'info') {
export function addToast(message, type = "info") {
const id = Date.now() + Math.random();
const newToast = { id, message, type };
toasts.update(all => [newToast, ...all]);
toasts.update((all) => [newToast, ...all]);
setTimeout(() => removeToast(id), 5000);
}
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) {
if (!startTime || !endTime) return 0;
if (endTime === 'manual') return parseFloat(startTime) || 0;
if (endTime === "manual") return parseFloat(startTime) || 0;
const parseTime = (timeStr) => {
const parts = timeStr.split(':');
const parts = timeStr.split(":");
if (parts.length !== 2) return 0;
return parseFloat(parts[0]) + parseFloat(parts[1]) / 60;
};
@ -16,15 +16,19 @@ export function calculateHours(startTime, endTime) {
}
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;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
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) {
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;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
return d.getUTCFullYear();
@ -34,18 +38,22 @@ export function getDateOfISOWeek(w, y) {
const simple = new Date(y, 0, 1 + (w - 1) * 7);
const dow = simple.getDay();
const ISOweekStart = simple;
if (dow <= 4)
ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1);
else
ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay());
if (dow <= 4) ISOweekStart.setDate(simple.getDate() - simple.getDay() + 1);
else ISOweekStart.setDate(simple.getDate() + 8 - simple.getDay());
return ISOweekStart;
}
export function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
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 './app.css';
import App from './App.svelte';
import { mount } from "svelte";
import "./app.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById('app'),
target: document.getElementById("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} */
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}
};

View file

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

View file

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