This commit is contained in:
2026-04-24 10:49:56 +02:00
commit 669521b4dd
41 changed files with 6143 additions and 0 deletions

12
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import type { User } from '$lib/server/db/schema';
declare global {
namespace App {
interface Locals {
user: User | null;
session: string | null;
}
}
}
export {};

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

18
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Handle } from '@sveltejs/kit';
import { validateSession, SESSION_COOKIE } from '$lib/server/auth/session';
export const handle: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get(SESSION_COOKIE);
if (sessionId) {
const user = await validateSession(sessionId);
if (user) {
event.locals.user = user;
event.locals.session = sessionId;
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
}
}
return resolve(event);
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcrypt';
const ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}

View File

@@ -0,0 +1,38 @@
import { db } from '../db';
import { sessions, users } from '../db/schema';
import { eq, and, gt } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import type { User } from '../db/schema';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage
export async function createSession(userId: string): Promise<string> {
const sessionId = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt
});
return sessionId;
}
export async function validateSession(sessionId: string): Promise<User | null> {
const result = await db
.select({ user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(and(eq(sessions.id, sessionId), gt(sessions.expiresAt, new Date())))
.limit(1);
if (result.length === 0) return null;
return result[0].user;
}
export async function deleteSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export const SESSION_COOKIE = 'session';

View File

@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
const pool = new Pool({
connectionString: env.DATABASE_URL
});
export const db = drizzle(pool, { schema });

View File

@@ -0,0 +1,76 @@
import { pgTable, uuid, varchar, text, boolean, integer, decimal, time, date, timestamp, pgEnum } from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('role', ['mitarbeiter', 'admin']);
export const absenceTypeEnum = pgEnum('absence_type', ['urlaub', 'krank', 'sonderurlaub']);
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: text('password_hash'),
role: roleEnum('role').notNull().default('mitarbeiter'),
bundesland: varchar('bundesland', { length: 2 }).notNull().default('MV'),
standardStart: time('standard_start').notNull().default('08:00'),
standardEnd: time('standard_end').notNull().default('17:00'),
breakMinutesDefault: integer('break_minutes_default').notNull().default(30),
weeklyHours: decimal('weekly_hours', { precision: 4, scale: 1 }).notNull().default('40.0'),
vacationDaysPerYear: integer('vacation_days_per_year').notNull().default(30),
active: boolean('active').notNull().default(true),
inviteToken: text('invite_token'),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const timeEntries = pgTable('time_entries', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
date: date('date').notNull(),
startTime: time('start_time'),
endTime: time('end_time'),
breakMinutes: integer('break_minutes').notNull().default(30),
isAutoFilled: boolean('is_auto_filled').notNull().default(false),
isCorrected: boolean('is_corrected').notNull().default(false),
confirmedAt: timestamp('confirmed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow()
});
export const absences = pgTable('absences', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
dateFrom: date('date_from').notNull(),
dateTo: date('date_to').notNull(),
type: absenceTypeEnum('type').notNull(),
notes: text('notes'),
createdAt: timestamp('created_at').notNull().defaultNow()
});
export const holidays = pgTable('holidays', {
id: uuid('id').primaryKey().defaultRandom(),
bundesland: varchar('bundesland', { length: 2 }).notNull(),
date: date('date').notNull(),
name: varchar('name', { length: 200 }).notNull(),
year: integer('year').notNull()
});
export const vacationBalances = pgTable('vacation_balances', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
year: integer('year').notNull(),
totalDays: integer('total_days').notNull(),
carriedOverDays: integer('carried_over_days').notNull().default(0)
});
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type TimeEntry = typeof timeEntries.$inferSelect;
export type Absence = typeof absences.$inferSelect;
export type Holiday = typeof holidays.$inferSelect;
export type VacationBalance = typeof vacationBalances.$inferSelect;

118
src/lib/server/time.ts Normal file
View File

@@ -0,0 +1,118 @@
import { db } from './db';
import { timeEntries, absences, holidays, vacationBalances } from './db/schema';
import { eq, and, gte, lte, between } from 'drizzle-orm';
export function calculateBreakMinutes(startTime: string, endTime: string): number {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
const totalMinutes = (eh * 60 + em) - (sh * 60 + sm);
if (totalMinutes > 9 * 60) return 45;
if (totalMinutes > 6 * 60) return 30;
return 0;
}
export function calculateWorkMinutes(startTime: string, endTime: string, breakMinutes: number): number {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
const totalMinutes = (eh * 60 + em) - (sh * 60 + sm);
return Math.max(0, totalMinutes - breakMinutes);
}
export function formatMinutes(minutes: number): string {
const h = Math.floor(Math.abs(minutes) / 60);
const m = Math.abs(minutes) % 60;
const sign = minutes < 0 ? '-' : '';
return `${sign}${h}:${m.toString().padStart(2, '0')}`;
}
export async function getOrCreateTodayEntry(userId: string, standardStart: string, standardEnd: string, breakMinutesDefault: number) {
const today = new Date().toISOString().split('T')[0];
const [existing] = await db
.select()
.from(timeEntries)
.where(and(eq(timeEntries.userId, userId), eq(timeEntries.date, today)))
.limit(1);
if (existing) return existing;
const [created] = await db
.insert(timeEntries)
.values({
userId,
date: today,
startTime: standardStart,
endTime: standardEnd,
breakMinutes: breakMinutesDefault,
isAutoFilled: true
})
.returning();
return created;
}
export async function getMonthEntries(userId: string, year: number, month: number) {
const from = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const to = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
return db
.select()
.from(timeEntries)
.where(and(eq(timeEntries.userId, userId), between(timeEntries.date, from, to)));
}
export async function getMonthAbsences(userId: string, year: number, month: number) {
const from = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const to = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
return db
.select()
.from(absences)
.where(and(eq(absences.userId, userId), lte(absences.dateFrom, to), gte(absences.dateTo, from)));
}
export async function getHolidays(bundesland: string, year: number) {
return db
.select()
.from(holidays)
.where(and(eq(holidays.bundesland, bundesland), eq(holidays.year, year)));
}
export async function getVacationBalance(userId: string, year: number) {
const [balance] = await db
.select()
.from(vacationBalances)
.where(and(eq(vacationBalances.userId, userId), eq(vacationBalances.year, year)))
.limit(1);
return balance ?? null;
}
export async function countUsedVacationDays(userId: string, year: number): Promise<number> {
const from = `${year}-01-01`;
const to = `${year}-12-31`;
const rows = await db
.select()
.from(absences)
.where(
and(
eq(absences.userId, userId),
eq(absences.type, 'urlaub'),
lte(absences.dateFrom, to),
gte(absences.dateTo, from)
)
);
let total = 0;
for (const row of rows) {
const start = new Date(Math.max(new Date(row.dateFrom).getTime(), new Date(from).getTime()));
const end = new Date(Math.min(new Date(row.dateTo).getTime(), new Date(to).getTime()));
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const day = d.getDay();
if (day !== 0 && day !== 6) total++;
}
}
return total;
}

View File

@@ -0,0 +1,12 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, url }) => {
const publicPaths = ['/login', '/invite'];
const isPublic = publicPaths.some((p) => url.pathname.startsWith(p));
if (!locals.user && !isPublic) {
return { user: null, redirectToLogin: true };
}
return { user: locals.user };
};

22
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,22 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
let { data, children } = $props();
const publicPaths = ['/login', '/invite'];
$effect(() => {
const isPublic = publicPaths.some((p) => $page.url.pathname.startsWith(p));
if (data.redirectToLogin && !isPublic) {
goto('/login');
}
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
redirect(302, '/dashboard');
};

1
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1 @@
<!-- Redirect handled by +page.server.ts -->

View File

@@ -0,0 +1,79 @@
import { redirect, fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { absences } from '$lib/server/db/schema';
import { eq, and, gte, lte } from 'drizzle-orm';
import { getHolidays, countUsedVacationDays, getVacationBalance } from '$lib/server/time';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
const user = locals.user;
const year = new Date().getFullYear();
const [userAbsences, holidays, vacBalance, usedVacation] = await Promise.all([
db.select().from(absences).where(
and(eq(absences.userId, user.id), gte(absences.dateTo, `${year}-01-01`))
),
getHolidays(user.bundesland, year),
getVacationBalance(user.id, year),
countUsedVacationDays(user.id, year)
]);
const totalVacation = vacBalance
? vacBalance.totalDays + vacBalance.carriedOverDays
: user.vacationDaysPerYear;
return {
user: { firstName: user.firstName, role: user.role },
absences: userAbsences,
holidays,
totalVacation,
usedVacation,
remainingVacation: totalVacation - usedVacation,
currentYear: year
};
};
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/login');
const data = await request.formData();
const dateFrom = data.get('dateFrom')?.toString();
const dateTo = data.get('dateTo')?.toString();
const type = data.get('type')?.toString() as 'urlaub' | 'krank' | 'sonderurlaub';
const notes = data.get('notes')?.toString() ?? null;
if (!dateFrom || !dateTo || !type) {
return fail(400, { error: 'Bitte alle Felder ausfüllen.' });
}
if (dateFrom > dateTo) {
return fail(400, { error: 'Enddatum muss nach Startdatum liegen.' });
}
await db.insert(absences).values({
userId: locals.user.id,
dateFrom,
dateTo,
type,
notes
});
return { success: true };
},
delete: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/login');
const data = await request.formData();
const id = data.get('id')?.toString();
if (!id) return fail(400, { error: 'Fehlende ID.' });
await db
.delete(absences)
.where(and(eq(absences.id, id), eq(absences.userId, locals.user.id)));
return { success: true };
}
};

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showForm = $state(false);
let submitting = $state(false);
const typeLabels: Record<string, string> = {
urlaub: '🏖 Urlaub',
krank: '🤒 Krankmeldung',
sonderurlaub: '📋 Sonderurlaub'
};
function formatDate(d: string) {
return new Date(d + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
}
</script>
<svelte:head>
<title>Abwesenheiten UniCon Zeiterfassung</title>
</svelte:head>
<div class="app">
<header>
<a href="/dashboard" class="back">← Zurück</a>
<span class="title">Abwesenheiten</span>
<nav>
{#if data.user.role === 'admin'}
<a href="/admin">Admin</a>
{/if}
</nav>
</header>
<main>
<!-- Vacation summary -->
<div class="summary-cards">
<div class="card">
<span class="num">{data.totalVacation}</span>
<span class="label">Urlaubstage gesamt</span>
</div>
<div class="card used">
<span class="num">{data.usedVacation}</span>
<span class="label">Verbraucht</span>
</div>
<div class="card remaining">
<span class="num">{data.remainingVacation}</span>
<span class="label">Verbleibend</span>
</div>
</div>
<!-- New absence form -->
<section class="section">
<div class="section-header">
<h3>Abwesenheit eintragen</h3>
<button class="btn-toggle" onclick={() => showForm = !showForm}>
{showForm ? 'Abbrechen' : '+ Neu'}
</button>
</div>
{#if showForm}
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST" action="?/create" use:enhance={() => {
submitting = true;
return async ({ update }) => { submitting = false; showForm = false; await update(); };
}} class="absence-form">
<div class="form-row">
<label>
Typ
<select name="type" required>
<option value="urlaub">Urlaub</option>
<option value="krank">Krankmeldung</option>
<option value="sonderurlaub">Sonderurlaub</option>
</select>
</label>
<label>
Von
<input type="date" name="dateFrom" required />
</label>
<label>
Bis
<input type="date" name="dateTo" required />
</label>
<label>
Notiz (optional)
<input type="text" name="notes" maxlength="200" placeholder="z. B. Arzttermin" />
</label>
<button type="submit" class="btn-save" disabled={submitting}>
{submitting ? '…' : 'Speichern'}
</button>
</div>
</form>
{/if}
</section>
<!-- List -->
<section class="section">
<h3>Meine Abwesenheiten {data.currentYear}</h3>
{#if data.absences.length === 0}
<p class="empty">Keine Einträge vorhanden.</p>
{:else}
<div class="absence-list">
{#each data.absences as absence}
<div class="absence-item">
<div class="absence-info">
<span class="type-label">{typeLabels[absence.type] ?? absence.type}</span>
<span class="dates">
{formatDate(absence.dateFrom)}
{absence.dateFrom !== absence.dateTo ? ` ${formatDate(absence.dateTo)}` : ''}
</span>
{#if absence.notes}
<span class="notes">{absence.notes}</span>
{/if}
</div>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={absence.id} />
<button type="submit" class="btn-delete" title="Löschen"></button>
</form>
</div>
{/each}
</div>
{/if}
</section>
<!-- Holidays -->
<section class="section">
<h3>Feiertage MV {data.currentYear}</h3>
<div class="holiday-list">
{#each data.holidays as h}
<div class="holiday-item">
<span>{new Date(h.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'long' })}</span>
<span>{h.name}</span>
</div>
{/each}
</div>
</section>
</main>
</div>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
.app { font-family: system-ui, sans-serif; min-height: 100vh; background: #f8f9fa; }
header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
position: sticky;
top: 0;
z-index: 10;
}
.back { color: #555; text-decoration: none; font-size: 0.9rem; }
.back:hover { color: #2563eb; }
.title { font-weight: 600; font-size: 1rem; flex: 1; }
nav a { color: #555; text-decoration: none; font-size: 0.9rem; }
main { max-width: 700px; margin: 0 auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 1.25rem; }
.summary-cards { display: flex; gap: 1rem; }
.card { flex: 1; background: white; border-radius: 8px; padding: 1rem; text-align: center; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.card .num { display: block; font-size: 2rem; font-weight: 700; color: #1a1a1a; }
.card .label { font-size: 0.8rem; color: #888; }
.card.used .num { color: #dc2626; }
.card.remaining .num { color: #16a34a; }
.section { background: white; border-radius: 10px; padding: 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.section h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 1rem; }
.btn-toggle {
background: #2563eb;
color: white;
border: none;
padding: 0.35rem 0.9rem;
border-radius: 5px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-toggle:hover { background: #1d4ed8; }
.absence-form { margin-top: 0.75rem; }
.form-row { display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; }
.form-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: #555; }
.form-row select, .form-row input { padding: 0.4rem 0.5rem; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 0.9rem; }
.btn-save {
background: #16a34a;
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-save:disabled { opacity: 0.6; }
.absence-list { display: flex; flex-direction: column; gap: 0.5rem; }
.absence-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.75rem;
background: #f8f9fa;
border-radius: 6px;
}
.absence-info { display: flex; flex-direction: column; gap: 0.15rem; }
.type-label { font-weight: 500; font-size: 0.9rem; }
.dates { font-size: 0.8rem; color: #555; }
.notes { font-size: 0.75rem; color: #888; font-style: italic; }
.btn-delete {
background: none;
border: none;
cursor: pointer;
color: #dc2626;
padding: 0.3rem 0.5rem;
border-radius: 3px;
font-size: 0.85rem;
}
.btn-delete:hover { background: #fef2f2; }
.holiday-list { display: flex; flex-direction: column; gap: 0.4rem; }
.holiday-item {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid #f0f0f0;
font-size: 0.875rem;
color: #555;
}
.empty { color: #aaa; font-size: 0.875rem; text-align: center; padding: 1rem; }
.error { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; margin-bottom: 0.75rem; }
@media (max-width: 500px) {
.summary-cards { flex-direction: column; }
.form-row { flex-direction: column; }
}
</style>

View File

@@ -0,0 +1,116 @@
import { redirect, fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users, timeEntries, absences } from '$lib/server/db/schema';
import { eq, and, between, desc } from 'drizzle-orm';
import { hashPassword } from '$lib/server/auth/password';
import { randomBytes } from 'crypto';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
if (locals.user.role !== 'admin') redirect(302, '/dashboard');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const from = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const to = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
const allUsers = await db
.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
email: users.email,
role: users.role,
active: users.active,
standardStart: users.standardStart,
standardEnd: users.standardEnd,
weeklyHours: users.weeklyHours,
vacationDaysPerYear: users.vacationDaysPerYear,
bundesland: users.bundesland,
inviteToken: users.inviteToken
})
.from(users)
.orderBy(users.lastName);
const monthEntries = await db
.select()
.from(timeEntries)
.where(between(timeEntries.date, from, to));
const monthAbsences = await db
.select()
.from(absences)
.where(and(between(absences.dateFrom, from, to)));
return {
currentUser: { id: locals.user.id, firstName: locals.user.firstName },
users: allUsers,
monthEntries,
monthAbsences,
month,
year
};
};
export const actions: Actions = {
createUser: async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') redirect(302, '/login');
const data = await request.formData();
const firstName = data.get('firstName')?.toString().trim();
const lastName = data.get('lastName')?.toString().trim();
const email = data.get('email')?.toString().trim().toLowerCase();
const role = (data.get('role')?.toString() ?? 'mitarbeiter') as 'mitarbeiter' | 'admin';
const weeklyHours = data.get('weeklyHours')?.toString() ?? '40.0';
const vacationDaysPerYear = parseInt(data.get('vacationDaysPerYear')?.toString() ?? '30');
if (!firstName || !lastName || !email) {
return fail(400, { createError: 'Bitte alle Pflichtfelder ausfüllen.' });
}
const inviteToken = randomBytes(24).toString('hex');
try {
await db.insert(users).values({
firstName,
lastName,
email,
role,
weeklyHours,
vacationDaysPerYear,
inviteToken
});
} catch {
return fail(400, { createError: 'E-Mail bereits vorhanden.' });
}
return { createSuccess: true, inviteToken };
},
toggleActive: async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') redirect(302, '/login');
const data = await request.formData();
const id = data.get('id')?.toString();
const active = data.get('active') === 'true';
if (!id) return fail(400, {});
await db.update(users).set({ active: !active }).where(eq(users.id, id));
return { success: true };
},
resetInvite: async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') redirect(302, '/login');
const data = await request.formData();
const id = data.get('id')?.toString();
if (!id) return fail(400, {});
const inviteToken = randomBytes(24).toString('hex');
await db.update(users).set({ inviteToken, passwordHash: null }).where(eq(users.id, id));
return { resetToken: inviteToken };
}
};

View File

@@ -0,0 +1,329 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let tab = $state<'overview' | 'users'>('overview');
let showCreateForm = $state(false);
let submitting = $state(false);
let newToken = $state<string | null>(form?.inviteToken ?? form?.resetToken ?? null);
$effect(() => {
if (form?.inviteToken) newToken = form.inviteToken;
if (form?.resetToken) newToken = form.resetToken;
});
function getEntryCount(userId: string): number {
return data.monthEntries.filter((e) => e.userId === userId).length;
}
function getAbsenceCount(userId: string): number {
return data.monthAbsences.filter((a) => a.userId === userId).length;
}
function calcWorkHours(start: string | null, end: string | null, breakMin: number): number {
if (!start || !end) return 0;
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
return Math.max(0, (eh * 60 + em) - (sh * 60 + sm) - breakMin) / 60;
}
function getTotalHours(userId: string): string {
const entries = data.monthEntries.filter((e) => e.userId === userId);
const total = entries.reduce((sum, e) => sum + calcWorkHours(e.startTime, e.endTime, e.breakMinutes), 0);
const h = Math.floor(total);
const m = Math.round((total - h) * 60);
return `${h}:${String(m).padStart(2, '0')}`;
}
function getConfirmedCount(userId: string): number {
return data.monthEntries.filter((e) => e.userId === userId && e.confirmedAt).length;
}
const monthName = new Date(data.year, data.month - 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
function copyInviteLink(token: string) {
navigator.clipboard.writeText(`${window.location.origin}/invite/${token}`);
}
</script>
<svelte:head>
<title>Admin UniCon Zeiterfassung</title>
</svelte:head>
<div class="app">
<header>
<a href="/dashboard" class="back">← Zurück</a>
<span class="title">Admin</span>
<span class="greeting">Hallo, {data.currentUser.firstName}</span>
</header>
<main>
<div class="tabs">
<button class:active={tab === 'overview'} onclick={() => tab = 'overview'}>Monatsübersicht</button>
<button class:active={tab === 'users'} onclick={() => tab = 'users'}>Mitarbeiterverwaltung</button>
</div>
{#if tab === 'overview'}
<section class="section">
<h3>Zeiterfassung {monthName}</h3>
<div class="overview-table">
<div class="ov-head">
<span>Mitarbeiter</span>
<span>Einträge</span>
<span>Bestätigt</span>
<span>Arbeitsstunden</span>
<span>Abwesenheiten</span>
<span>Aktionen</span>
</div>
{#each data.users.filter(u => u.active) as user}
<div class="ov-row">
<span class="name">{user.lastName}, {user.firstName}</span>
<span>{getEntryCount(user.id)}</span>
<span>{getConfirmedCount(user.id)}</span>
<span>{getTotalHours(user.id)} Std</span>
<span>{getAbsenceCount(user.id)}</span>
<span>
<a href="/admin/print/{user.id}/{data.year}/{data.month}" class="btn-print" target="_blank">
Drucken
</a>
</span>
</div>
{/each}
</div>
</section>
{:else}
<section class="section">
<div class="section-header">
<h3>Mitarbeiter</h3>
<button class="btn-primary" onclick={() => showCreateForm = !showCreateForm}>
{showCreateForm ? 'Abbrechen' : '+ Neu anlegen'}
</button>
</div>
{#if newToken}
<div class="invite-box">
<p>Einladungslink bereit. Diesen Link an den Mitarbeiter senden:</p>
<div class="invite-link-row">
<code>{typeof window !== 'undefined' ? window.location.origin : ''}/invite/{newToken}</code>
<button onclick={() => copyInviteLink(newToken!)}>Kopieren</button>
</div>
<button class="close-btn" onclick={() => newToken = null}>Schließen</button>
</div>
{/if}
{#if form?.createError}
<p class="error">{form.createError}</p>
{/if}
{#if showCreateForm}
<form method="POST" action="?/createUser" use:enhance={() => {
submitting = true;
return async ({ update }) => { submitting = false; showCreateForm = false; await update(); };
}} class="create-form">
<div class="form-grid">
<label>Vorname * <input type="text" name="firstName" required /></label>
<label>Nachname * <input type="text" name="lastName" required /></label>
<label>E-Mail * <input type="email" name="email" required /></label>
<label>
Rolle
<select name="role">
<option value="mitarbeiter">Mitarbeiter</option>
<option value="admin">Admin</option>
</select>
</label>
<label>Wochenstunden <input type="number" name="weeklyHours" value="40" min="1" max="60" step="0.5" /></label>
<label>Urlaubstage/Jahr <input type="number" name="vacationDaysPerYear" value="30" min="0" max="60" /></label>
</div>
<button type="submit" class="btn-save" disabled={submitting}>
{submitting ? '…' : 'Mitarbeiter anlegen & Einladungslink generieren'}
</button>
</form>
{/if}
<div class="user-list">
{#each data.users as user}
<div class="user-item" class:inactive={!user.active}>
<div class="user-info">
<span class="user-name">{user.firstName} {user.lastName}</span>
<span class="user-meta">{user.email} · {user.role} · {user.weeklyHours}h/Woche · {user.vacationDaysPerYear} Urlaubstage</span>
{#if !user.active}
<span class="inactive-badge">Inaktiv</span>
{/if}
</div>
<div class="user-actions">
<a href="/print/{user.id}" class="btn-sm" target="_blank">Drucken</a>
<form method="POST" action="?/resetInvite" use:enhance>
<input type="hidden" name="id" value={user.id} />
<button type="submit" class="btn-sm">Einladung zurücksetzen</button>
</form>
<form method="POST" action="?/toggleActive" use:enhance>
<input type="hidden" name="id" value={user.id} />
<input type="hidden" name="active" value={String(user.active)} />
<button type="submit" class="btn-sm" class:danger={user.active}>
{user.active ? 'Deaktivieren' : 'Aktivieren'}
</button>
</form>
</div>
</div>
{/each}
</div>
</section>
{/if}
</main>
</div>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
.app { font-family: system-ui, sans-serif; min-height: 100vh; background: #f8f9fa; }
header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
position: sticky;
top: 0;
z-index: 10;
}
.back { color: #555; text-decoration: none; font-size: 0.9rem; }
.back:hover { color: #2563eb; }
.title { font-weight: 600; font-size: 1rem; flex: 1; }
.greeting { font-size: 0.875rem; color: #666; }
main { max-width: 960px; margin: 0 auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 1.25rem; }
.tabs { display: flex; gap: 0; border-bottom: 2px solid #e5e7eb; }
.tabs button {
background: none;
border: none;
padding: 0.6rem 1.25rem;
cursor: pointer;
font-size: 0.9rem;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tabs button.active { color: #2563eb; border-bottom-color: #2563eb; font-weight: 500; }
.section { background: white; border-radius: 10px; padding: 1.5rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.section h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 1rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.overview-table { display: flex; flex-direction: column; gap: 1px; }
.ov-head, .ov-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.2fr 1fr 1fr;
padding: 0.5rem 0.5rem;
align-items: center;
font-size: 0.875rem;
}
.ov-head { font-size: 0.75rem; color: #888; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.ov-row { border-radius: 4px; }
.ov-row:hover { background: #f8f9fa; }
.name { font-weight: 500; }
.btn-print {
display: inline-block;
padding: 0.25rem 0.6rem;
background: #f3f4f6;
border-radius: 4px;
text-decoration: none;
color: #555;
font-size: 0.8rem;
}
.btn-print:hover { background: #e5e7eb; }
.btn-primary {
background: #2563eb;
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 5px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-primary:hover { background: #1d4ed8; }
.invite-box {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.invite-box p { font-size: 0.875rem; margin-bottom: 0.5rem; }
.invite-link-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.invite-link-row code { font-size: 0.8rem; background: white; padding: 0.3rem 0.5rem; border-radius: 4px; border: 1px solid #d0d0d0; word-break: break-all; }
.invite-link-row button, .close-btn {
padding: 0.3rem 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.8rem;
margin-top: 0.5rem;
}
.create-form { margin-bottom: 1.5rem; }
.form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 0.75rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: #555; }
.form-grid input, .form-grid select { padding: 0.4rem 0.5rem; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 0.9rem; }
.btn-save {
background: #16a34a;
color: white;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 5px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-save:disabled { opacity: 0.6; }
.user-list { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; }
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8f9fa;
border-radius: 6px;
gap: 1rem;
flex-wrap: wrap;
}
.user-item.inactive { opacity: 0.55; }
.user-info { display: flex; flex-direction: column; gap: 0.2rem; }
.user-name { font-weight: 500; font-size: 0.9rem; }
.user-meta { font-size: 0.78rem; color: #777; }
.inactive-badge { font-size: 0.7rem; color: #dc2626; background: #fef2f2; padding: 0.1rem 0.4rem; border-radius: 3px; width: fit-content; }
.user-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.btn-sm {
padding: 0.25rem 0.6rem;
border: 1px solid #d0d0d0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 0.78rem;
color: #555;
text-decoration: none;
display: inline-block;
}
.btn-sm:hover { background: #f5f5f5; }
.btn-sm.danger { color: #dc2626; border-color: #fecaca; }
.btn-sm.danger:hover { background: #fef2f2; }
.error { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; margin-bottom: 0.75rem; }
@media (max-width: 640px) {
.form-grid { grid-template-columns: 1fr 1fr; }
.ov-head, .ov-row { grid-template-columns: 2fr 1fr 1fr 1fr; }
.ov-head span:nth-child(n+5), .ov-row span:nth-child(n+5) { display: none; }
}
</style>

View File

@@ -0,0 +1,53 @@
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import {
getMonthEntries,
getMonthAbsences,
getHolidays,
calculateWorkMinutes,
formatMinutes
} from '$lib/server/time';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.user) redirect(302, '/login');
if (locals.user.role !== 'admin') redirect(302, '/dashboard');
const year = parseInt(params.year);
const month = parseInt(params.month);
const userId = params.userId;
const [targetUser] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!targetUser) redirect(302, '/admin');
const [entries, monthAbsences, holidays] = await Promise.all([
getMonthEntries(userId, year, month),
getMonthAbsences(userId, year, month),
getHolidays(targetUser.bundesland, year)
]);
const totalWorkMinutes = entries.reduce((sum, e) => {
if (!e.startTime || !e.endTime) return sum;
return sum + calculateWorkMinutes(e.startTime, e.endTime, e.breakMinutes);
}, 0);
return {
user: {
firstName: targetUser.firstName,
lastName: targetUser.lastName,
email: targetUser.email,
weeklyHours: targetUser.weeklyHours,
standardStart: targetUser.standardStart,
standardEnd: targetUser.standardEnd
},
entries,
absences: monthAbsences,
holidays,
year,
month,
totalWorkMinutes,
totalFormatted: formatMinutes(totalWorkMinutes)
};
};

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const monthName = new Date(data.year, data.month - 1).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric'
});
const lastDay = new Date(data.year, data.month, 0).getDate();
const days = Array.from({ length: lastDay }, (_, i) => {
const d = String(i + 1).padStart(2, '0');
return `${data.year}-${String(data.month).padStart(2, '0')}-${d}`;
});
function getEntry(date: string) {
return data.entries.find((e) => e.date === date);
}
function getAbsence(date: string) {
return data.absences.find((a) => a.dateFrom <= date && a.dateTo >= date);
}
function getHoliday(date: string) {
return data.holidays.find((h) => h.date === date);
}
function isWeekend(date: string) {
const d = new Date(date + 'T00:00:00');
return d.getDay() === 0 || d.getDay() === 6;
}
function formatTime(t: string | null | undefined) {
if (!t) return '';
return t.slice(0, 5);
}
function calcHours(start: string | null, end: string | null, breakMin: number): string {
if (!start || !end) return '';
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm) - breakMin;
if (mins <= 0) return '0:00';
return `${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')}`;
}
function weekdayShort(date: string) {
return new Date(date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short' });
}
function dayNum(date: string) {
return new Date(date + 'T00:00:00').getDate();
}
const absenceLabels: Record<string, string> = {
urlaub: 'Urlaub',
krank: 'Krank',
sonderurlaub: 'Sonderurlaub'
};
</script>
<svelte:head>
<title>Stundennachweis {monthName} {data.user.lastName}</title>
</svelte:head>
<div class="print-page">
<div class="header-block">
<div>
<h1>Stundennachweis</h1>
<h2>{monthName}</h2>
</div>
<div class="employee-info">
<strong>{data.user.firstName} {data.user.lastName}</strong><br />
{data.user.email}<br />
Standardzeit: {formatTime(data.user.standardStart)} {formatTime(data.user.standardEnd)}<br />
Wochenstunden: {data.user.weeklyHours}h
</div>
</div>
<table>
<thead>
<tr>
<th>Tag</th>
<th>Beginn</th>
<th>Ende</th>
<th>Pause</th>
<th>Arbeitszeit</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
{#each days as day}
{@const entry = getEntry(day)}
{@const absence = getAbsence(day)}
{@const holiday = getHoliday(day)}
{@const weekend = isWeekend(day)}
<tr class:weekend class:holiday={!!holiday}>
<td class="date-cell">
<span class="weekday">{weekdayShort(day)}</span>
<span class="daynum">{dayNum(day)}.</span>
</td>
{#if holiday}
<td colspan="4" class="remark-cell">{holiday.name}</td>
<td></td>
{:else if absence}
<td colspan="4" class="remark-cell">{absenceLabels[absence.type] ?? absence.type}</td>
<td class="remark-cell">{absence.notes ?? ''}</td>
{:else if weekend}
<td colspan="5" class="remark-cell muted"></td>
{:else}
<td>{formatTime(entry?.startTime)}</td>
<td>{formatTime(entry?.endTime)}</td>
<td>{entry?.breakMinutes != null ? entry.breakMinutes + ' Min' : ''}</td>
<td class="hours">{entry ? calcHours(entry.startTime, entry.endTime, entry.breakMinutes) : ''}</td>
<td class="remark-cell">
{#if entry?.isAutoFilled && !entry?.confirmedAt}
<span class="auto-note">Standard (unbestätigt)</span>
{:else if entry?.isCorrected}
Korrigiert
{:else if entry?.confirmedAt}
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="4"><strong>Gesamt Arbeitszeit</strong></td>
<td class="hours"><strong>{data.totalFormatted} Std</strong></td>
<td></td>
</tr>
</tfoot>
</table>
<div class="signature-block">
<div class="sig-field">
<div class="sig-line"></div>
<p>Datum, Unterschrift Mitarbeiter</p>
</div>
<div class="sig-field">
<div class="sig-line"></div>
<p>Datum, Unterschrift Vorgesetzter</p>
</div>
</div>
<div class="print-btn no-print">
<button onclick={() => window.print()}>Drucken / Als PDF speichern</button>
</div>
</div>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) { background: white; }
.print-page {
font-family: 'Arial', sans-serif;
font-size: 10pt;
max-width: 210mm;
margin: 0 auto;
padding: 15mm 15mm 10mm;
color: #000;
}
.header-block {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8mm;
border-bottom: 2px solid #000;
padding-bottom: 4mm;
}
h1 { font-size: 16pt; font-weight: 700; }
h2 { font-size: 12pt; font-weight: 400; color: #444; }
.employee-info { font-size: 9pt; line-height: 1.5; text-align: right; }
table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 8mm;
}
th {
background: #f0f0f0;
border: 1px solid #ccc;
padding: 3pt 5pt;
text-align: left;
font-weight: 600;
}
td {
border: 1px solid #ddd;
padding: 2.5pt 5pt;
vertical-align: middle;
}
tr.weekend { background: #f9f9f9; color: #999; }
tr.holiday { background: #fffde7; }
.date-cell { display: flex; gap: 4pt; white-space: nowrap; }
.weekday { color: #777; min-width: 20pt; }
.daynum { font-weight: 500; }
.remark-cell { color: #555; font-size: 8pt; }
.muted { color: #bbb; }
.hours { font-weight: 500; }
.auto-note { color: #999; font-style: italic; }
.total-row { background: #f5f5f5; }
.total-row td { border-top: 2px solid #888; }
.signature-block {
display: flex;
gap: 30mm;
margin-top: 15mm;
}
.sig-field { flex: 1; }
.sig-line { border-bottom: 1px solid #000; height: 15mm; margin-bottom: 3pt; }
.sig-field p { font-size: 8pt; color: #555; }
.print-btn {
margin-top: 8mm;
text-align: center;
}
.print-btn button {
padding: 0.6rem 1.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
}
.print-btn button:hover { background: #1d4ed8; }
@media print {
.no-print { display: none; }
.print-page { padding: 0; max-width: none; }
@page { margin: 15mm; size: A4; }
}
</style>

View File

@@ -0,0 +1,133 @@
import { redirect, fail } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { timeEntries } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import {
getOrCreateTodayEntry,
getMonthEntries,
getMonthAbsences,
getHolidays,
getVacationBalance,
countUsedVacationDays,
calculateBreakMinutes
} from '$lib/server/time';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
const user = locals.user;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const todayEntry = await getOrCreateTodayEntry(
user.id,
user.standardStart,
user.standardEnd,
user.breakMinutesDefault
);
const [monthEntries, monthAbsences, mvHolidays, vacBalance] = await Promise.all([
getMonthEntries(user.id, year, month),
getMonthAbsences(user.id, year, month),
getHolidays(user.bundesland, year),
getVacationBalance(user.id, year)
]);
const usedVacation = await countUsedVacationDays(user.id, year);
const totalVacation = vacBalance
? vacBalance.totalDays + vacBalance.carriedOverDays
: user.vacationDaysPerYear;
const remainingVacation = totalVacation - usedVacation;
return {
user: {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
standardStart: user.standardStart,
standardEnd: user.standardEnd,
breakMinutesDefault: user.breakMinutesDefault
},
todayEntry,
monthEntries,
monthAbsences,
holidays: mvHolidays,
remainingVacation,
totalVacation,
currentDate: now.toISOString().split('T')[0]
};
};
export const actions: Actions = {
saveEntry: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/login');
const data = await request.formData();
const date = data.get('date')?.toString();
const startTime = data.get('startTime')?.toString();
const endTime = data.get('endTime')?.toString();
const breakMinutesRaw = data.get('breakMinutes')?.toString();
if (!date || !startTime || !endTime) {
return fail(400, { error: 'Bitte alle Felder ausfüllen.' });
}
const breakMinutes = breakMinutesRaw
? parseInt(breakMinutesRaw)
: calculateBreakMinutes(startTime, endTime);
const [existing] = await db
.select()
.from(timeEntries)
.where(and(eq(timeEntries.userId, locals.user.id), eq(timeEntries.date, date)))
.limit(1);
if (existing) {
await db
.update(timeEntries)
.set({
startTime,
endTime,
breakMinutes,
isAutoFilled: false,
isCorrected: true,
updatedAt: new Date()
})
.where(eq(timeEntries.id, existing.id));
} else {
await db.insert(timeEntries).values({
userId: locals.user.id,
date,
startTime,
endTime,
breakMinutes,
isAutoFilled: false
});
}
return { success: true };
},
confirmToday: async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
const today = new Date().toISOString().split('T')[0];
const [entry] = await db
.select()
.from(timeEntries)
.where(and(eq(timeEntries.userId, locals.user.id), eq(timeEntries.date, today)))
.limit(1);
if (entry) {
await db
.update(timeEntries)
.set({ confirmedAt: new Date(), updatedAt: new Date() })
.where(eq(timeEntries.id, entry.id));
}
return { confirmed: true };
}
};

View File

@@ -0,0 +1,441 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const today = data.currentDate;
const todayEntry = $derived(data.todayEntry);
let editingDate = $state<string | null>(null);
let confirmLoading = $state(false);
let saveLoading = $state(false);
function formatTime(t: string | null | undefined): string {
if (!t) return '';
return t.slice(0, 5);
}
function calcWorkHours(start: string | null, end: string | null, breakMin: number): string {
if (!start || !end) return '';
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm) - breakMin;
if (mins <= 0) return '0:00';
return `${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')}`;
}
function isWeekend(dateStr: string): boolean {
const d = new Date(dateStr + 'T00:00:00');
return d.getDay() === 0 || d.getDay() === 6;
}
function isHoliday(dateStr: string): string | null {
return data.holidays.find((h) => h.date === dateStr)?.name ?? null;
}
function getAbsenceForDate(dateStr: string) {
return data.monthAbsences.find(
(a) => a.dateFrom <= dateStr && a.dateTo >= dateStr
);
}
function getEntryForDate(dateStr: string) {
return data.monthEntries.find((e) => e.date === dateStr);
}
// Build calendar days for current month
const calendarDays = $derived.by(() => {
const [y, m] = today.split('-').map(Number);
const lastDay = new Date(y, m, 0).getDate();
return Array.from({ length: lastDay }, (_, i) => {
const day = String(i + 1).padStart(2, '0');
return `${y}-${String(m).padStart(2, '0')}-${day}`;
});
});
const isConfirmedToday = $derived(!!todayEntry?.confirmedAt);
</script>
<svelte:head>
<title>Zeiterfassung UniCon</title>
</svelte:head>
<div class="app">
<!-- Header -->
<header>
<span class="brand">UniCon Zeiterfassung</span>
<nav>
{#if data.user.role === 'admin'}
<a href="/admin">Admin</a>
{/if}
<a href="/absences">Abwesenheiten</a>
<form method="POST" action="/logout">
<button type="submit" class="logout-btn">Abmelden</button>
</form>
</nav>
</header>
<main>
<!-- Today card -->
<section class="today-card">
<div class="today-header">
<div>
<h2>Heute, {new Date(today + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}</h2>
<p class="greeting">Hallo, {data.user.firstName}!</p>
</div>
<div class="vacation-badge">
<span class="vac-days">{data.remainingVacation}</span>
<span class="vac-label">Urlaubstage verbleibend</span>
</div>
</div>
{#if todayEntry}
<div class="today-times">
<div class="time-block">
<span class="time-label">Beginn</span>
<span class="time-value">{formatTime(todayEntry.startTime)}</span>
</div>
<div class="time-block">
<span class="time-label">Ende</span>
<span class="time-value">{formatTime(todayEntry.endTime)}</span>
</div>
<div class="time-block">
<span class="time-label">Pause</span>
<span class="time-value">{todayEntry.breakMinutes} Min</span>
</div>
<div class="time-block highlight">
<span class="time-label">Arbeitszeit</span>
<span class="time-value">{calcWorkHours(todayEntry.startTime, todayEntry.endTime, todayEntry.breakMinutes)} Std</span>
</div>
</div>
{#if todayEntry.isAutoFilled}
<p class="auto-note">Standardzeiten eingetragen. Bitte bestätigen oder anpassen.</p>
{/if}
<div class="today-actions">
{#if !isConfirmedToday}
<form method="POST" action="?/confirmToday" use:enhance={() => {
confirmLoading = true;
return async ({ update }) => { confirmLoading = false; await update(); };
}}>
<button type="submit" class="btn-confirm" disabled={confirmLoading}>
{confirmLoading ? '…' : '✓ Heute bestätigen'}
</button>
</form>
{:else}
<span class="confirmed-badge">✓ Bestätigt</span>
{/if}
<button class="btn-edit" onclick={() => editingDate = editingDate === today ? null : today}>
{editingDate === today ? 'Abbrechen' : 'Zeiten anpassen'}
</button>
</div>
{#if editingDate === today}
<form method="POST" action="?/saveEntry" class="edit-form" use:enhance={() => {
saveLoading = true;
return async ({ update }) => { saveLoading = false; editingDate = null; await update(); };
}}>
<input type="hidden" name="date" value={today} />
<div class="edit-row">
<label>Beginn <input type="time" name="startTime" value={todayEntry.startTime?.slice(0,5)} required /></label>
<label>Ende <input type="time" name="endTime" value={todayEntry.endTime?.slice(0,5)} required /></label>
<label>Pause (Min) <input type="number" name="breakMinutes" value={todayEntry.breakMinutes} min="0" max="120" /></label>
<button type="submit" class="btn-save" disabled={saveLoading}>{saveLoading ? '…' : 'Speichern'}</button>
</div>
</form>
{/if}
{/if}
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
</section>
<!-- Month overview -->
<section class="month-section">
<h3>Monatsübersicht</h3>
<div class="month-table">
<div class="table-head">
<span>Tag</span>
<span>Beginn</span>
<span>Ende</span>
<span>Pause</span>
<span>Arbeitszeit</span>
<span>Status</span>
<span></span>
</div>
{#each calendarDays as day}
{@const entry = getEntryForDate(day)}
{@const absence = getAbsenceForDate(day)}
{@const holiday = isHoliday(day)}
{@const weekend = isWeekend(day)}
{@const isToday = day === today}
<div class="table-row" class:weekend class:is-today={isToday} class:holiday={!!holiday}>
<span class="day-label">
{new Date(day + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric', month: 'numeric' })}
{#if isToday}<span class="today-dot"></span>{/if}
</span>
{#if holiday}
<span class="span-info" colspan="4">{holiday}</span>
{:else if absence}
<span class="span-info">
{absence.type === 'urlaub' ? '🏖 Urlaub' : absence.type === 'krank' ? '🤒 Krank' : '📋 Sonderurlaub'}
</span>
{:else if weekend}
<span class="span-info muted">Wochenende</span>
{:else}
<span>{formatTime(entry?.startTime)}</span>
<span>{formatTime(entry?.endTime)}</span>
<span>{entry?.breakMinutes ?? ''} {entry ? 'Min' : ''}</span>
<span>{entry ? calcWorkHours(entry.startTime, entry.endTime, entry.breakMinutes) + ' Std' : ''}</span>
<span>
{#if entry?.confirmedAt}
<span class="badge green"></span>
{:else if entry?.isAutoFilled}
<span class="badge yellow">Auto</span>
{:else if entry}
<span class="badge blue">Manuell</span>
{:else}
<span class="badge gray"></span>
{/if}
</span>
<span>
{#if !weekend && !holiday && !absence && day <= today}
<button class="edit-row-btn" onclick={() => editingDate = editingDate === day ? null : day}>
{editingDate === day ? '✕' : '✏'}
</button>
{/if}
</span>
{/if}
</div>
{#if editingDate === day && day !== today}
<div class="inline-edit">
<form method="POST" action="?/saveEntry" use:enhance={() => {
saveLoading = true;
return async ({ update }) => { saveLoading = false; editingDate = null; await update(); };
}}>
<input type="hidden" name="date" value={day} />
<div class="edit-row">
<label>Beginn <input type="time" name="startTime" value={entry?.startTime?.slice(0,5) ?? '08:00'} required /></label>
<label>Ende <input type="time" name="endTime" value={entry?.endTime?.slice(0,5) ?? '17:00'} required /></label>
<label>Pause <input type="number" name="breakMinutes" value={entry?.breakMinutes ?? 30} min="0" max="120" /></label>
<button type="submit" class="btn-save" disabled={saveLoading}>Speichern</button>
<button type="button" onclick={() => editingDate = null}>Abbrechen</button>
</div>
</form>
</div>
{/if}
{/each}
</div>
</section>
</main>
</div>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
.app {
font-family: system-ui, sans-serif;
min-height: 100vh;
background: #f8f9fa;
color: #1a1a1a;
}
header {
background: white;
border-bottom: 1px solid #e5e7eb;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-weight: 700; font-size: 1rem; color: #2563eb; }
nav { display: flex; align-items: center; gap: 1rem; }
nav a { color: #555; text-decoration: none; font-size: 0.9rem; }
nav a:hover { color: #2563eb; }
.logout-btn {
background: none;
border: 1px solid #d0d0d0;
padding: 0.3rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
color: #555;
}
.logout-btn:hover { background: #f5f5f5; }
main { max-width: 900px; margin: 0 auto; padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; }
/* Today card */
.today-card {
background: white;
border-radius: 10px;
padding: 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.today-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.25rem; }
.today-header h2 { font-size: 1.1rem; font-weight: 600; }
.greeting { color: #666; font-size: 0.9rem; margin-top: 0.2rem; }
.vacation-badge {
text-align: center;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 0.5rem 1rem;
}
.vac-days { display: block; font-size: 1.5rem; font-weight: 700; color: #2563eb; }
.vac-label { font-size: 0.75rem; color: #555; }
.today-times { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.time-block { background: #f8f9fa; border-radius: 6px; padding: 0.75rem 1rem; text-align: center; min-width: 90px; }
.time-block.highlight { background: #eff6ff; }
.time-label { display: block; font-size: 0.75rem; color: #888; margin-bottom: 0.25rem; }
.time-value { font-size: 1.1rem; font-weight: 600; }
.auto-note { font-size: 0.85rem; color: #f59e0b; background: #fffbeb; padding: 0.4rem 0.75rem; border-radius: 4px; margin-bottom: 0.75rem; }
.today-actions { display: flex; gap: 0.75rem; align-items: center; }
.btn-confirm {
background: #16a34a;
color: white;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
}
.btn-confirm:hover:not(:disabled) { background: #15803d; }
.btn-confirm:disabled { opacity: 0.6; cursor: not-allowed; }
.confirmed-badge { color: #16a34a; font-weight: 500; font-size: 0.9rem; }
.btn-edit {
background: none;
border: 1px solid #d0d0d0;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-edit:hover { background: #f5f5f5; }
.edit-form { margin-top: 1rem; }
.edit-row { display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; }
.edit-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.8rem; color: #555; }
.edit-row input { padding: 0.4rem 0.5rem; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 0.9rem; }
.btn-save {
background: #2563eb;
color: white;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-save:hover:not(:disabled) { background: #1d4ed8; }
.btn-save:disabled { opacity: 0.6; }
.error { color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; padding: 0.5rem 0.75rem; border-radius: 4px; font-size: 0.875rem; margin-top: 0.5rem; }
/* Month table */
.month-section {
background: white;
border-radius: 10px;
padding: 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.month-section h3 { margin-bottom: 1rem; font-size: 1rem; font-weight: 600; }
.month-table { display: flex; flex-direction: column; gap: 1px; }
.table-head {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1.2fr 1fr 0.5fr;
padding: 0.4rem 0.5rem;
font-size: 0.75rem;
color: #888;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.table-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1.2fr 1fr 0.5fr;
padding: 0.4rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
align-items: center;
transition: background 0.1s;
}
.table-row:hover { background: #f8f9fa; }
.table-row.weekend { color: #aaa; }
.table-row.is-today { background: #eff6ff; font-weight: 500; }
.table-row.holiday { background: #fef9c3; color: #92400e; }
.day-label { display: flex; align-items: center; gap: 0.4rem; }
.today-dot { color: #2563eb; font-size: 0.5rem; }
.span-info { grid-column: 2 / 7; color: #888; font-style: italic; font-size: 0.8rem; }
.muted { color: #ccc; }
.badge {
display: inline-block;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
}
.badge.green { background: #dcfce7; color: #16a34a; }
.badge.yellow { background: #fef9c3; color: #a16207; }
.badge.blue { background: #dbeafe; color: #2563eb; }
.badge.gray { background: #f3f4f6; color: #9ca3af; }
.edit-row-btn {
background: none;
border: none;
cursor: pointer;
color: #888;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.85rem;
}
.edit-row-btn:hover { background: #f0f0f0; color: #2563eb; }
.inline-edit {
background: #f8f9fa;
border-radius: 4px;
padding: 0.75rem;
margin: 2px 0;
}
.inline-edit button {
padding: 0.35rem 0.75rem;
border-radius: 4px;
border: 1px solid #d0d0d0;
background: white;
cursor: pointer;
font-size: 0.8rem;
}
@media (max-width: 640px) {
.table-head, .table-row { grid-template-columns: 2fr 1fr 1fr 1fr; }
.table-head span:nth-child(n+5), .table-row span:nth-child(n+5) { display: none; }
.today-times { gap: 0.5rem; }
.time-block { min-width: 70px; }
}
</style>

View File

@@ -0,0 +1,63 @@
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { hashPassword } from '$lib/server/auth/password';
import { createSession, SESSION_COOKIE } from '$lib/server/auth/session';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const [user] = await db
.select({ id: users.id, firstName: users.firstName, email: users.email })
.from(users)
.where(eq(users.inviteToken, params.token))
.limit(1);
if (!user) {
redirect(302, '/login');
}
return { firstName: user.firstName, email: user.email };
};
export const actions: Actions = {
default: async ({ params, request, cookies }) => {
const data = await request.formData();
const password = data.get('password')?.toString();
const confirm = data.get('confirm')?.toString();
if (!password || password.length < 8) {
return fail(400, { error: 'Passwort muss mindestens 8 Zeichen haben.' });
}
if (password !== confirm) {
return fail(400, { error: 'Passwörter stimmen nicht überein.' });
}
const [user] = await db
.select()
.from(users)
.where(eq(users.inviteToken, params.token))
.limit(1);
if (!user) {
return fail(400, { error: 'Ungültiger Einladungslink.' });
}
const passwordHash = await hashPassword(password);
await db
.update(users)
.set({ passwordHash, inviteToken: null })
.where(eq(users.id, user.id));
const sessionId = await createSession(user.id);
cookies.set(SESSION_COOKIE, sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60
});
redirect(302, '/dashboard');
}
};

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let loading = $state(false);
</script>
<svelte:head>
<title>Passwort setzen UniCon Zeiterfassung</title>
</svelte:head>
<main>
<div class="invite-card">
<h1>Willkommen, {data.firstName}!</h1>
<p class="subtitle">Bitte setze dein Passwort für <strong>{data.email}</strong>.</p>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<label>
Passwort (min. 8 Zeichen)
<input type="password" name="password" required minlength="8" autocomplete="new-password" />
</label>
<label>
Passwort wiederholen
<input type="password" name="confirm" required autocomplete="new-password" />
</label>
<button type="submit" disabled={loading}>
{loading ? 'Wird gespeichert…' : 'Passwort setzen & Anmelden'}
</button>
</form>
</div>
</main>
<style>
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
font-family: system-ui, sans-serif;
}
.invite-card {
background: white;
padding: 2.5rem;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 380px;
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.4rem;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
color: #666;
font-size: 0.9rem;
margin: 0 0 1.5rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 1rem;
font-size: 0.875rem;
color: #555;
font-weight: 500;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 5px;
font-size: 1rem;
transition: border-color 0.15s;
}
input:focus {
outline: none;
border-color: #2563eb;
}
button {
width: 100%;
padding: 0.7rem;
background: #2563eb;
color: white;
border: none;
border-radius: 5px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-top: 0.5rem;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #1d4ed8;
}
button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 5px;
padding: 0.6rem 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,52 @@
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { verifyPassword } from '$lib/server/auth/password';
import { createSession, SESSION_COOKIE } from '$lib/server/auth/session';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(302, '/dashboard');
}
return {};
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get('email')?.toString().trim().toLowerCase();
const password = data.get('password')?.toString();
if (!email || !password) {
return fail(400, { error: 'E-Mail und Passwort sind erforderlich.' });
}
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!user || !user.active) {
return fail(401, { error: 'E-Mail oder Passwort ungültig.' });
}
if (!user.passwordHash) {
return fail(401, { error: 'Kein Passwort gesetzt. Bitte Einladungslink nutzen.' });
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
return fail(401, { error: 'E-Mail oder Passwort ungültig.' });
}
const sessionId = await createSession(user.id);
cookies.set(SESSION_COOKIE, sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60
});
redirect(302, '/dashboard');
}
};

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let loading = $state(false);
</script>
<svelte:head>
<title>Login UniCon Zeiterfassung</title>
</svelte:head>
<main>
<div class="login-card">
<h1>UniCon Zeiterfassung</h1>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<label>
E-Mail
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Passwort
<input type="password" name="password" required autocomplete="current-password" />
</label>
<button type="submit" disabled={loading}>
{loading ? 'Wird angemeldet…' : 'Anmelden'}
</button>
</form>
</div>
</main>
<style>
main {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
font-family: system-ui, sans-serif;
}
.login-card {
background: white;
padding: 2.5rem;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 380px;
}
h1 {
margin: 0 0 1.5rem;
font-size: 1.4rem;
font-weight: 600;
color: #1a1a1a;
text-align: center;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 1rem;
font-size: 0.875rem;
color: #555;
font-weight: 500;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d0d0d0;
border-radius: 5px;
font-size: 1rem;
transition: border-color 0.15s;
}
input:focus {
outline: none;
border-color: #2563eb;
}
button {
width: 100%;
padding: 0.7rem;
background: #2563eb;
color: white;
border: none;
border-radius: 5px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-top: 0.5rem;
transition: background 0.15s;
}
button:hover:not(:disabled) {
background: #1d4ed8;
}
button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.error {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 5px;
padding: 0.6rem 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import { deleteSession, SESSION_COOKIE } from '$lib/server/auth/session';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ cookies, locals }) => {
if (locals.session) {
await deleteSession(locals.session);
}
cookies.delete(SESSION_COOKIE, { path: '/' });
redirect(302, '/login');
}
};

View File

@@ -0,0 +1,49 @@
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import {
getMonthEntries,
getMonthAbsences,
getHolidays,
calculateWorkMinutes,
formatMinutes
} from '$lib/server/time';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.user) redirect(302, '/login');
const year = parseInt(params.year);
const month = parseInt(params.month);
const userId = locals.user.id;
const [entries, monthAbsences, holidays] = await Promise.all([
getMonthEntries(userId, year, month),
getMonthAbsences(userId, year, month),
getHolidays(locals.user.bundesland, year)
]);
const totalWorkMinutes = entries.reduce((sum, e) => {
if (!e.startTime || !e.endTime) return sum;
return sum + calculateWorkMinutes(e.startTime, e.endTime, e.breakMinutes);
}, 0);
return {
user: {
firstName: locals.user.firstName,
lastName: locals.user.lastName,
email: locals.user.email,
weeklyHours: locals.user.weeklyHours,
standardStart: locals.user.standardStart,
standardEnd: locals.user.standardEnd
},
entries,
absences: monthAbsences,
holidays,
year,
month,
totalWorkMinutes,
totalFormatted: formatMinutes(totalWorkMinutes)
};
};

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const monthName = new Date(data.year, data.month - 1).toLocaleDateString('de-DE', {
month: 'long',
year: 'numeric'
});
const lastDay = new Date(data.year, data.month, 0).getDate();
const days = Array.from({ length: lastDay }, (_, i) => {
const d = String(i + 1).padStart(2, '0');
return `${data.year}-${String(data.month).padStart(2, '0')}-${d}`;
});
function getEntry(date: string) {
return data.entries.find((e) => e.date === date);
}
function getAbsence(date: string) {
return data.absences.find((a) => a.dateFrom <= date && a.dateTo >= date);
}
function getHoliday(date: string) {
return data.holidays.find((h) => h.date === date);
}
function isWeekend(date: string) {
const d = new Date(date + 'T00:00:00');
return d.getDay() === 0 || d.getDay() === 6;
}
function formatTime(t: string | null | undefined) {
if (!t) return '';
return t.slice(0, 5);
}
function calcHours(start: string | null, end: string | null, breakMin: number): string {
if (!start || !end) return '';
const [sh, sm] = start.split(':').map(Number);
const [eh, em] = end.split(':').map(Number);
const mins = (eh * 60 + em) - (sh * 60 + sm) - breakMin;
if (mins <= 0) return '0:00';
return `${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')}`;
}
function weekdayShort(date: string) {
return new Date(date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short' });
}
function dayNum(date: string) {
return new Date(date + 'T00:00:00').getDate();
}
const absenceLabels: Record<string, string> = {
urlaub: 'Urlaub',
krank: 'Krank',
sonderurlaub: 'Sonderurlaub'
};
</script>
<svelte:head>
<title>Stundennachweis {monthName} {data.user.lastName}</title>
</svelte:head>
<div class="print-page">
<div class="header-block">
<div>
<h1>Stundennachweis</h1>
<h2>{monthName}</h2>
</div>
<div class="employee-info">
<strong>{data.user.firstName} {data.user.lastName}</strong><br />
{data.user.email}<br />
Standardzeit: {formatTime(data.user.standardStart)} {formatTime(data.user.standardEnd)}<br />
Wochenstunden: {data.user.weeklyHours}h
</div>
</div>
<table>
<thead>
<tr>
<th>Tag</th>
<th>Beginn</th>
<th>Ende</th>
<th>Pause</th>
<th>Arbeitszeit</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
{#each days as day}
{@const entry = getEntry(day)}
{@const absence = getAbsence(day)}
{@const holiday = getHoliday(day)}
{@const weekend = isWeekend(day)}
<tr class:weekend class:holiday={!!holiday}>
<td class="date-cell">
<span class="weekday">{weekdayShort(day)}</span>
<span class="daynum">{dayNum(day)}.</span>
</td>
{#if holiday}
<td colspan="4" class="remark-cell">{holiday.name}</td>
<td></td>
{:else if absence}
<td colspan="4" class="remark-cell">{absenceLabels[absence.type] ?? absence.type}</td>
<td class="remark-cell">{absence.notes ?? ''}</td>
{:else if weekend}
<td colspan="5" class="remark-cell muted"></td>
{:else}
<td>{formatTime(entry?.startTime)}</td>
<td>{formatTime(entry?.endTime)}</td>
<td>{entry?.breakMinutes != null ? entry.breakMinutes + ' Min' : ''}</td>
<td class="hours">{entry ? calcHours(entry.startTime, entry.endTime, entry.breakMinutes) : ''}</td>
<td class="remark-cell">
{#if entry?.isAutoFilled && !entry?.confirmedAt}
<span class="auto-note">Standard (unbestätigt)</span>
{:else if entry?.isCorrected}
Korrigiert
{:else if entry?.confirmedAt}
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="4"><strong>Gesamt Arbeitszeit</strong></td>
<td class="hours"><strong>{data.totalFormatted} Std</strong></td>
<td></td>
</tr>
</tfoot>
</table>
<div class="signature-block">
<div class="sig-field">
<div class="sig-line"></div>
<p>Datum, Unterschrift Mitarbeiter</p>
</div>
<div class="sig-field">
<div class="sig-line"></div>
<p>Datum, Unterschrift Vorgesetzter</p>
</div>
</div>
<div class="print-btn no-print">
<button onclick={() => window.print()}>Drucken / Als PDF speichern</button>
</div>
</div>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:global(body) { background: white; }
.print-page {
font-family: 'Arial', sans-serif;
font-size: 10pt;
max-width: 210mm;
margin: 0 auto;
padding: 15mm 15mm 10mm;
color: #000;
}
.header-block {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8mm;
border-bottom: 2px solid #000;
padding-bottom: 4mm;
}
h1 { font-size: 16pt; font-weight: 700; }
h2 { font-size: 12pt; font-weight: 400; color: #444; }
.employee-info { font-size: 9pt; line-height: 1.5; text-align: right; }
table {
width: 100%;
border-collapse: collapse;
font-size: 9pt;
margin-bottom: 8mm;
}
th {
background: #f0f0f0;
border: 1px solid #ccc;
padding: 3pt 5pt;
text-align: left;
font-weight: 600;
}
td {
border: 1px solid #ddd;
padding: 2.5pt 5pt;
vertical-align: middle;
}
tr.weekend { background: #f9f9f9; color: #999; }
tr.holiday { background: #fffde7; }
.date-cell { display: flex; gap: 4pt; white-space: nowrap; }
.weekday { color: #777; min-width: 20pt; }
.daynum { font-weight: 500; }
.remark-cell { color: #555; font-size: 8pt; }
.muted { color: #bbb; }
.hours { font-weight: 500; }
.auto-note { color: #999; font-style: italic; }
.total-row { background: #f5f5f5; }
.total-row td { border-top: 2px solid #888; }
.signature-block {
display: flex;
gap: 30mm;
margin-top: 15mm;
}
.sig-field { flex: 1; }
.sig-line { border-bottom: 1px solid #000; height: 15mm; margin-bottom: 3pt; }
.sig-field p { font-size: 8pt; color: #555; }
.print-btn {
margin-top: 8mm;
text-align: center;
}
.print-btn button {
padding: 0.6rem 1.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
}
.print-btn button:hover { background: #1d4ed8; }
@media print {
.no-print { display: none; }
.print-page { padding: 0; max-width: none; }
@page { margin: 15mm; size: A4; }
}
</style>