init
This commit is contained in:
12
src/app.d.ts
vendored
Normal file
12
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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
18
src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
11
src/lib/server/auth/password.ts
Normal file
11
src/lib/server/auth/password.ts
Normal 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);
|
||||
}
|
||||
38
src/lib/server/auth/session.ts
Normal file
38
src/lib/server/auth/session.ts
Normal 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';
|
||||
10
src/lib/server/db/index.ts
Normal file
10
src/lib/server/db/index.ts
Normal 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 });
|
||||
76
src/lib/server/db/schema.ts
Normal file
76
src/lib/server/db/schema.ts
Normal 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
118
src/lib/server/time.ts
Normal 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;
|
||||
}
|
||||
12
src/routes/+layout.server.ts
Normal file
12
src/routes/+layout.server.ts
Normal 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
22
src/routes/+layout.svelte
Normal 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()}
|
||||
9
src/routes/+page.server.ts
Normal file
9
src/routes/+page.server.ts
Normal 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
1
src/routes/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Redirect handled by +page.server.ts -->
|
||||
79
src/routes/absences/+page.server.ts
Normal file
79
src/routes/absences/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
247
src/routes/absences/+page.svelte
Normal file
247
src/routes/absences/+page.svelte
Normal 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>
|
||||
116
src/routes/admin/+page.server.ts
Normal file
116
src/routes/admin/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
329
src/routes/admin/+page.svelte
Normal file
329
src/routes/admin/+page.svelte
Normal 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>
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
247
src/routes/admin/print/[userId]/[year]/[month]/+page.svelte
Normal file
247
src/routes/admin/print/[userId]/[year]/[month]/+page.svelte
Normal 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>
|
||||
133
src/routes/dashboard/+page.server.ts
Normal file
133
src/routes/dashboard/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
441
src/routes/dashboard/+page.svelte
Normal file
441
src/routes/dashboard/+page.svelte
Normal 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>
|
||||
63
src/routes/invite/[token]/+page.server.ts
Normal file
63
src/routes/invite/[token]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
136
src/routes/invite/[token]/+page.svelte
Normal file
136
src/routes/invite/[token]/+page.svelte
Normal 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>
|
||||
52
src/routes/login/+page.server.ts
Normal file
52
src/routes/login/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
130
src/routes/login/+page.svelte
Normal file
130
src/routes/login/+page.svelte
Normal 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>
|
||||
13
src/routes/logout/+page.server.ts
Normal file
13
src/routes/logout/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
49
src/routes/print/[year]/[month]/+page.server.ts
Normal file
49
src/routes/print/[year]/[month]/+page.server.ts
Normal 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)
|
||||
};
|
||||
};
|
||||
247
src/routes/print/[year]/[month]/+page.svelte
Normal file
247
src/routes/print/[year]/[month]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user