fix: fix error user dont get updated targetWorkingHours
also perform code formatting and cleanup
This commit is contained in:
parent
3fadb6d86d
commit
8fe3d71dde
21 changed files with 2348 additions and 1235 deletions
|
|
@ -1,14 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<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>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
switch (type) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
$: 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,18 +138,18 @@
|
|||
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);
|
||||
if (entriesToSave.length > 0) await saveTimeEntriesBatch(entriesToSave);
|
||||
weekEditMode = false;
|
||||
await loadData();
|
||||
} finally {
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
if (isValidTimeRange(entry.startTime, entry.endTime)) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
if (!newUser.username || !newUser.password) {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -78,8 +100,8 @@ async function request(endpoint, method = 'GET', body = null, isBlob = false) {
|
|||
|
||||
try {
|
||||
const jsonErr = JSON.parse(errText);
|
||||
if(jsonErr.message) errorMsg = jsonErr.message;
|
||||
} catch(e) {}
|
||||
if (jsonErr.message) errorMsg = jsonErr.message;
|
||||
} catch (e) {}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
|
@ -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');
|
||||
if(!sy) return null;
|
||||
return { ...sy, startDate: sy.start_date, endDate: sy.end_date, isActive: sy.is_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,
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue