Initial commit: OMSORG website + Mitarbeiter-App

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-06-13 20:03:22 +02:00
co-authored by Claude Sonnet 4.6
commit 8beb0fcf52
103 changed files with 7384 additions and 0 deletions
View File
+171
View File
@@ -0,0 +1,171 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_admin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php');
exit;
}
/* ── Login attempts ────────────────────────────────────────────────── */
if (($_POST['_login_action'] ?? '') === 'clear') {
db()->exec('DELETE FROM login_attempts');
header('Location: ../pages/admin.php?tab=login&cleared=1');
exit;
}
/* ── User actions ──────────────────────────────────────────────────── */
if (!empty($_POST['_user_action'])) {
$action = $_POST['_user_action'];
$user_id = (int)($_POST['user_id'] ?? 0);
$back = '../pages/admin.php?tab=nutzer';
switch ($action) {
case 'add_user': {
$username = trim($_POST['username'] ?? '');
$uname = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$telefon = trim($_POST['telefon'] ?? '');
$role = in_array($_POST['role'] ?? '', ['user', 'admin'], true) ? $_POST['role'] : 'user';
$password = $_POST['password'] ?? '';
if ($username === '' || $uname === '' || strlen($password) < 6) {
header('Location: ' . $back . '&user_msg=' . urlencode('Benutzername, Name und Passwort (mind. 6 Zeichen) sind Pflichtfelder.'));
exit;
}
try {
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = db()->prepare(
'INSERT INTO users (username, name, role, password_hash, active, created_at, email, telefon)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)'
);
$stmt->execute([$username, $uname, $role, $hash, date('c'), $email, $telefon]);
} catch (\PDOException $ex) {
$msg = str_contains($ex->getMessage(), 'UNIQUE') ? 'Benutzername bereits vergeben.' : 'Datenbankfehler.';
header('Location: ' . $back . '&user_msg=' . urlencode($msg));
exit;
}
header('Location: ' . $back . '&user_ok=1&user_msg=' . urlencode('Nutzer erstellt.'));
exit;
}
case 'edit_user': {
$uname = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$telefon = trim($_POST['telefon'] ?? '');
$role = in_array($_POST['role'] ?? '', ['user', 'admin'], true) ? $_POST['role'] : 'user';
$active = (int)(bool)($_POST['active'] ?? 0);
if ($user_id < 1 || $uname === '') {
header('Location: ' . $back . '&user_msg=' . urlencode('Ungültige Eingabe.'));
exit;
}
$stmt = db()->prepare(
'UPDATE users SET name=?, email=?, telefon=?, role=?, active=? WHERE id=?'
);
$stmt->execute([$uname, $email, $telefon, $role, $active, $user_id]);
header('Location: ' . $back . '&user_ok=1&user_msg=' . urlencode('Nutzer gespeichert.'));
exit;
}
case 'change_password': {
$password = $_POST['new_password'] ?? '';
if ($user_id < 1 || strlen($password) < 6) {
header('Location: ' . $back . '&user_msg=' . urlencode('Passwort muss mindestens 6 Zeichen lang sein.'));
exit;
}
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = db()->prepare('UPDATE users SET password_hash=? WHERE id=?');
$stmt->execute([$hash, $user_id]);
header('Location: ' . $back . '&user_ok=1&user_msg=' . urlencode('Passwort geändert.'));
exit;
}
case 'delete_user': {
if ($user_id < 1) {
header('Location: ' . $back . '&user_msg=' . urlencode('Ungültige Nutzer-ID.'));
exit;
}
$stmt = db()->prepare('SELECT username FROM users WHERE id=?');
$stmt->execute([$user_id]);
$row = $stmt->fetch();
if (!$row) {
header('Location: ' . $back . '&user_msg=' . urlencode('Nutzer nicht gefunden.'));
exit;
}
if ($row['username'] === current_username()) {
header('Location: ' . $back . '&user_msg=' . urlencode('Den eigenen Account kann man nicht löschen.'));
exit;
}
$stmt = db()->prepare('DELETE FROM users WHERE id=?');
$stmt->execute([$user_id]);
header('Location: ' . $back . '&user_ok=1&user_msg=' . urlencode('Nutzer gelöscht.'));
exit;
}
default:
header('Location: ' . $back . '&user_msg=' . urlencode('Unbekannte Aktion.'));
exit;
}
}
/* ── Request actions ───────────────────────────────────────────────── */
$request_id = (int)($_POST['request_id'] ?? 0);
$action = $_POST['action'] ?? '';
$admin_note = trim($_POST['admin_note'] ?? '');
$type = $_POST['type'] ?? '';
$allowed_types = ['urlaubsantrag', 'abwesenheitsantrag', 'benefitsantrag', 'werben', 'fortbildungsantrag', 'stundennachweis'];
if (!in_array($type, $allowed_types, true)) {
header('Location: ../pages/admin.php?error=' . urlencode('Ungültiger Antragstyp.'));
exit;
}
/* ── Delete request ────────────────────────────────────────────────── */
if ($action === 'delete') {
if ($request_id < 1) {
header('Location: ../pages/admin.php?error=' . urlencode('Ungültige Antrags-ID.'));
exit;
}
$table = 'requests_' . $type;
$stmt = db()->prepare("SELECT * FROM $table WHERE id = ?");
$stmt->execute([$request_id]);
$row = $stmt->fetch();
if (!$row) {
header('Location: ../pages/admin.php?error=' . urlencode('Antrag nicht gefunden.'));
exit;
}
// Delete associated file for types that have uploads
if (in_array($type, ['stundennachweis', 'fortbildungsantrag'], true) && !empty($row['filename'])) {
$file_path = __DIR__ . '/../uploads/' . $row['filename'];
if (is_file($file_path)) {
unlink($file_path);
}
}
$stmt = db()->prepare("DELETE FROM $table WHERE id = ?");
$stmt->execute([$request_id]);
header('Location: ../pages/admin.php?deleted=1');
exit;
}
/* ── Status update ─────────────────────────────────────────────────── */
if (!in_array($action, ['accepted', 'rejected'], true) || $request_id < 1) {
header('Location: ../pages/admin.php?error=' . urlencode('Ungültige Aktion.'));
exit;
}
$table = 'requests_' . $type;
$stmt = db()->prepare("SELECT id FROM $table WHERE id = ?");
$stmt->execute([$request_id]);
if (!$stmt->fetch()) {
header('Location: ../pages/admin.php?error=' . urlencode('Antrag nicht gefunden.'));
exit;
}
$stmt = db()->prepare("UPDATE $table SET status = ?, admin_note = ?, updated_at = ? WHERE id = ?");
$stmt->execute([$action, $admin_note, date('c'), $request_id]);
header('Location: ../pages/admin.php?type=' . urlencode($type) . '&id=' . $request_id . '&updated=1');
exit;
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/dokumentenarchiv.php');
exit;
}
$id = (int) ($_POST['id'] ?? 0);
if ($id <= 0) {
http_response_code(400);
echo 'Ungültige Anfrage.';
exit;
}
$stmt = db()->prepare('SELECT * FROM dokumente WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch();
if (!$doc) {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
$user = current_user();
if ((int) $doc['user_id'] !== (int) $user['id'] && !is_admin()) {
http_response_code(403);
echo 'Keine Berechtigung.';
exit;
}
$file_path = dirname(__DIR__) . '/uploads/' . $doc['filename'];
if (is_file($file_path)) {
unlink($file_path);
}
db()->prepare('DELETE FROM dokumente WHERE id = ?')->execute([$id]);
header('Location: ../pages/dokumentenarchiv.php?deleted=1');
exit;
@@ -0,0 +1,77 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_admin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php?tab=downloads');
exit;
}
$action = $_POST['_action'] ?? '';
/* ── Add ──────────────────────────────────────────────────────────────── */
if ($action === 'add') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if ($title === '') {
header('Location: ../pages/admin.php?tab=downloads&err=notitle');
exit;
}
$up = handle_upload($_FILES['file'] ?? [], [
'allowed' => UPLOAD_TYPES_OFFICE,
'dir' => dirname(__DIR__) . '/downloads/',
'naming' => 'hash',
'max_bytes' => 20 * 1024 * 1024,
'messages' => [
'missing' => 'nofile', 'upload' => 'nofile',
'ext' => 'badext', 'mime' => 'badmime',
'size' => 'toobig', 'move' => 'upload',
],
]);
if (!$up['ok']) {
header('Location: ../pages/admin.php?tab=downloads&err=' . $up['error']);
exit;
}
$safe_name = $up['filename'];
$original_name = $up['original_name'];
$sort_order = (int)db()->query('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM downloads')->fetchColumn();
$now = date('c');
$stmt = db()->prepare(
'INSERT INTO downloads (title, description, filename, original_name, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$title, $description, $safe_name, $original_name, $sort_order, $now, $now]);
header('Location: ../pages/admin.php?tab=downloads&ok=added');
exit;
}
/* ── Delete ───────────────────────────────────────────────────────────── */
if ($action === 'delete') {
$id = (int)($_POST['download_id'] ?? 0);
if ($id <= 0) {
header('Location: ../pages/admin.php?tab=downloads&err=invalid');
exit;
}
$stmt = db()->prepare('SELECT filename FROM downloads WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if ($row) {
db()->prepare('DELETE FROM downloads WHERE id = ?')->execute([$id]);
@unlink(dirname(__DIR__) . '/downloads/' . $row['filename']);
}
header('Location: ../pages/admin.php?tab=downloads&ok=deleted');
exit;
}
header('Location: ../pages/admin.php?tab=downloads');
exit;
@@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_admin();
header('Content-Type: application/json');
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') !== 'XMLHttpRequest') {
echo json_encode(['ok' => false, 'error' => 'forbidden']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'method']);
exit;
}
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$order = $data['order'] ?? null;
if (!is_array($order)) {
echo json_encode(['ok' => false, 'error' => 'invalid']);
exit;
}
$ids = array_values(array_filter(array_map('intval', $order), fn($v) => $v > 0));
if (empty($ids)) {
echo json_encode(['ok' => true]);
exit;
}
$now = date('c');
$pos = 10;
$stmt = db()->prepare('UPDATE downloads SET sort_order = ?, updated_at = ? WHERE id = ?');
db()->beginTransaction();
foreach ($ids as $id) {
$stmt->execute([$pos, $now, $id]);
$pos += 10;
}
db()->commit();
echo json_encode(['ok' => true]);
exit;
@@ -0,0 +1,92 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_admin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php?tab=einsatzanweisung');
exit;
}
verify_csrf();
$back = '../pages/admin.php?tab=einsatzanweisung';
$action = $_POST['_action'] ?? '';
$user_id = (int)($_POST['target_user_id'] ?? 0);
if ($user_id <= 0) {
header('Location: ' . $back . '&err=' . urlencode('Ungültiger Nutzer.'));
exit;
}
$stmt = db()->prepare('SELECT id FROM users WHERE id = ?');
$stmt->execute([$user_id]);
if (!$stmt->fetch()) {
header('Location: ' . $back . '&err=' . urlencode('Nutzer nicht gefunden.'));
exit;
}
if ($action === 'update_ort') {
$ort = trim($_POST['ort'] ?? '');
db()->prepare(
'INSERT INTO einsatzanweisung (user_id, ort)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET ort = excluded.ort'
)->execute([$user_id, $ort]);
header('Location: ' . $back . '&ok=' . urlencode('Einsatzort gespeichert.'));
exit;
}
if ($action === 'upload_pdf') {
$stmt = db()->prepare('SELECT u.username, e.filename AS old_filename FROM users u LEFT JOIN einsatzanweisung e ON e.user_id = u.id WHERE u.id = ?');
$stmt->execute([$user_id]);
$row = $stmt->fetch();
$dir = dirname(__DIR__) . '/uploads/';
$up = handle_upload($_FILES['pdf'] ?? [], [
'allowed' => UPLOAD_TYPES_PDF,
'dir' => $dir,
'prefix' => 'einsatzanweisung',
'username' => $row['username'],
'messages' => [
'missing' => 'Bitte eine PDF-Datei auswählen.',
'upload' => 'Fehler beim Hochladen.',
'ext' => 'Nur PDF-Dateien erlaubt.',
'mime' => 'Nur PDF-Dateien erlaubt.',
'size' => 'Datei ist zu groß (max. 12 MB).',
'move' => 'Fehler beim Speichern der Datei.',
],
]);
if (!$up['ok']) {
header('Location: ' . $back . '&err=' . urlencode($up['error']));
exit;
}
$original_name = $up['original_name'];
$safe_name = $up['filename'];
if (!empty($row['old_filename'])) {
$old_path = $dir . basename($row['old_filename']);
if (is_file($old_path)) {
unlink($old_path);
}
}
$now = date('c');
db()->prepare(
'INSERT INTO einsatzanweisung (user_id, filename, original_name, uploaded_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
filename = excluded.filename,
original_name = excluded.original_name,
uploaded_at = excluded.uploaded_at'
)->execute([$user_id, $safe_name, $original_name, $now]);
header('Location: ' . $back . '&ok=' . urlencode('PDF erfolgreich hochgeladen.'));
exit;
}
header('Location: ' . $back);
exit;
@@ -0,0 +1,77 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_admin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/admin.php?tab=fortbildung');
exit;
}
$action = $_POST['_action'] ?? '';
/* ── Add ──────────────────────────────────────────────────────────────── */
if ($action === 'add') {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if ($title === '') {
header('Location: ../pages/admin.php?tab=fortbildung&err=notitle');
exit;
}
$up = handle_upload($_FILES['file'] ?? [], [
'allowed' => UPLOAD_TYPES_OFFICE,
'dir' => dirname(__DIR__) . '/fortbildung-materials/',
'naming' => 'hash',
'max_bytes' => 20 * 1024 * 1024,
'messages' => [
'missing' => 'nofile', 'upload' => 'nofile',
'ext' => 'badext', 'mime' => 'badmime',
'size' => 'toobig', 'move' => 'upload',
],
]);
if (!$up['ok']) {
header('Location: ../pages/admin.php?tab=fortbildung&err=' . $up['error']);
exit;
}
$safe_name = $up['filename'];
$original_name = $up['original_name'];
$sort_order = (int)db()->query('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM fortbildung_materials')->fetchColumn();
$now = date('c');
$stmt = db()->prepare(
'INSERT INTO fortbildung_materials (title, description, filename, original_name, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$title, $description, $safe_name, $original_name, $sort_order, $now, $now]);
header('Location: ../pages/admin.php?tab=fortbildung&ok=added');
exit;
}
/* ── Delete ───────────────────────────────────────────────────────────── */
if ($action === 'delete') {
$id = (int)($_POST['material_id'] ?? 0);
if ($id <= 0) {
header('Location: ../pages/admin.php?tab=fortbildung&err=invalid');
exit;
}
$stmt = db()->prepare('SELECT filename FROM fortbildung_materials WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if ($row) {
db()->prepare('DELETE FROM fortbildung_materials WHERE id = ?')->execute([$id]);
@unlink(dirname(__DIR__) . '/fortbildung-materials/' . $row['filename']);
}
header('Location: ../pages/admin.php?tab=fortbildung&ok=deleted');
exit;
}
header('Location: ../pages/admin.php?tab=fortbildung');
exit;
@@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_admin();
header('Content-Type: application/json');
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') !== 'XMLHttpRequest') {
echo json_encode(['ok' => false, 'error' => 'forbidden']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => false, 'error' => 'method']);
exit;
}
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$order = $data['order'] ?? null;
if (!is_array($order)) {
echo json_encode(['ok' => false, 'error' => 'invalid']);
exit;
}
$ids = array_values(array_filter(array_map('intval', $order), fn($v) => $v > 0));
if (empty($ids)) {
echo json_encode(['ok' => true]);
exit;
}
$now = date('c');
$pos = 10;
$stmt = db()->prepare('UPDATE fortbildung_materials SET sort_order = ?, updated_at = ? WHERE id = ?');
db()->beginTransaction();
foreach ($ids as $id) {
$stmt->execute([$pos, $now, $id]);
$pos += 10;
}
db()->commit();
echo json_encode(['ok' => true]);
exit;
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: ../index.php');
exit;
+34
View File
@@ -0,0 +1,34 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_admin();
$action = $_POST['_action'] ?? '';
if ($action === 'add') {
$title = trim((string)($_POST['title'] ?? ''));
$text = trim((string)($_POST['text'] ?? ''));
$date = trim((string)($_POST['date'] ?? '')) ?: date('Y-m-d');
if ($title === '' || $text === '') {
header('Location: ../pages/admin.php?tab=news&err=empty');
exit;
}
$stmt = db()->prepare(
'INSERT INTO news (title, text, date, created_at) VALUES (?, ?, ?, ?)'
);
$stmt->execute([$title, $text, $date, date('c')]);
header('Location: ../pages/admin.php?tab=news&ok=added');
exit;
}
if ($action === 'delete') {
$id = (int)($_POST['news_id'] ?? 0);
if ($id > 0) {
db()->prepare('DELETE FROM news WHERE id = ?')->execute([$id]);
}
header('Location: ../pages/admin.php?tab=news&ok=deleted');
exit;
}
header('Location: ../pages/admin.php?tab=news');
exit;
@@ -0,0 +1,74 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_login();
$ajax = !empty($_POST['_ajax']);
function ajax_error(string $msg): never {
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
function ajax_ok(): never {
header('Content-Type: application/json');
echo json_encode(['ok' => true]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
if ($ajax) ajax_error('Ungültige Anfrage.');
header('Location: ../pages/dienstplan.php'); exit;
}
$user_id = current_user()['id'];
$year = (int)($_POST['year'] ?? 0);
$month = (int)($_POST['month'] ?? 0);
if ($year < 2020 || $year > 2099 || $month < 1 || $month > 12) {
if ($ajax) ajax_error('Ungültiger Monat.');
header('Location: ../pages/dienstplan.php'); exit;
}
$back = '../pages/dienstplan.php?year=' . $year . '&month=' . $month;
$month_str = sprintf('%04d-%02d', $year, $month);
$schicht_ok = ['frueh', 'spaet', 'nacht'];
$submitted = $_POST['schicht'] ?? [];
if (!is_array($submitted)) {
header('Location: ' . $back . '&saved=1'); exit;
}
$now = date('c');
db()->beginTransaction();
try {
foreach ($submitted as $date => $schicht) {
if (!is_string($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) continue;
if (substr($date, 0, 7) !== $month_str) continue;
[$y, $m, $d] = array_map('intval', explode('-', $date));
if (!checkdate($m, $d, $y)) continue;
if (in_array($schicht, $schicht_ok, true)) {
db()->prepare(
'INSERT INTO dienstplan (user_id, date, schicht, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE schicht=VALUES(schicht), updated_at=VALUES(updated_at)'
)->execute([$user_id, $date, $schicht, $now, $now]);
} else {
db()->prepare(
'DELETE FROM dienstplan WHERE user_id = ? AND date = ?'
)->execute([$user_id, $date]);
}
}
db()->commit();
} catch (\PDOException $ex) {
if (db()->inTransaction()) db()->rollBack();
$msg = 'Datenbankfehler: ' . $ex->getMessage();
if ($ajax) ajax_error($msg);
header('Location: ' . $back . '&error=' . urlencode($msg));
exit;
}
if ($ajax) ajax_ok();
header('Location: ' . $back . '&saved=1');
exit;
+53
View File
@@ -0,0 +1,53 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_login();
$action = $_POST['action'] ?? '';
$username = current_username();
if ($action === 'profile') {
$email = trim($_POST['email'] ?? '');
$telefon = trim($_POST['telefon'] ?? '');
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
header('Location: ../pages/settings.php?error=' . urlencode('Ungültige E-Mail-Adresse.'));
exit;
}
$stmt = db()->prepare('UPDATE users SET email = ?, telefon = ? WHERE username = ?');
$stmt->execute([$email, $telefon, $username]);
header('Location: ../pages/settings.php?saved=1');
exit;
}
if ($action === 'password') {
$old = $_POST['old_password'] ?? '';
$new = $_POST['new_password'] ?? '';
$repeat = $_POST['new_password_repeat'] ?? '';
$user = current_user();
if (!$user || !password_verify($old, $user['password_hash'])) {
header('Location: ../pages/settings.php?error=' . urlencode('Aktuelles Passwort ist falsch.'));
exit;
}
if (mb_strlen($new) < 8) {
header('Location: ../pages/settings.php?error=' . urlencode('Neues Passwort muss mindestens 8 Zeichen haben.'));
exit;
}
if ($new !== $repeat) {
header('Location: ../pages/settings.php?error=' . urlencode('Die neuen Passwörter stimmen nicht überein.'));
exit;
}
$hash = password_hash($new, PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
$stmt->execute([$hash, $username]);
header('Location: ../pages/settings.php?pwchanged=1');
exit;
}
header('Location: ../pages/settings.php');
exit;
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/mail.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/abwesenheitsantrag.php');
exit;
}
$back = '../pages/abwesenheitsantrag.php';
$von = trim($_POST['von'] ?? '');
$bis = trim($_POST['bis'] ?? '');
$grund = trim($_POST['grund'] ?? '');
$vertretung = trim($_POST['vertretung'] ?? '');
$nachricht = trim($_POST['nachricht'] ?? '');
if ($von === '' || $bis === '' || $grund === '') {
header('Location: ' . $back . '?error=' . urlencode('Von, Bis und Grund sind Pflichtfelder.'));
exit;
}
$allowed_grund = ['krankheit', 'arztbesuch', 'behoerdengang', 'sonderurlaub', 'elternzeit_pflegezeit', 'sonstiges'];
if (!in_array($grund, $allowed_grund, true)) {
header('Location: ' . $back . '?error=' . urlencode('Ungültiger Grund.'));
exit;
}
if (!strtotime($von) || !strtotime($bis)) {
header('Location: ' . $back . '?error=' . urlencode('Ungültiges Datum.'));
exit;
}
if (strtotime($von) > strtotime($bis)) {
header('Location: ' . $back . '?error=' . urlencode('Das Startdatum muss vor dem Enddatum liegen.'));
exit;
}
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO requests_abwesenheitsantrag (user_id, status, admin_note, created_at, updated_at, von, bis, grund, vertretung, nachricht)
VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?, ?)'
)->execute([$user_id, $now, $now, $von, $bis, $grund, $vertretung, $nachricht]);
$grund_labels = [
'krankheit' => 'Krankheit',
'arztbesuch' => 'Arztbesuch',
'behoerdengang' => 'Behördengang',
'sonderurlaub' => 'Sonderurlaub',
'elternzeit_pflegezeit' => 'Elternzeit / Pflegezeit',
'sonstiges' => 'Sonstiges',
];
$config = require __DIR__ . '/../lib/config.php';
smtp_send(
$config,
$config['mail_sabrina'],
'Neuer Abwesenheitsantrag: ' . current_name(),
"Ein Abwesenheitsantrag wurde eingereicht.\n\n"
. 'Mitarbeiter: ' . current_name() . "\n"
. 'Von: ' . $von . "\n"
. 'Bis: ' . $bis . "\n"
. 'Grund: ' . ($grund_labels[$grund] ?? $grund) . "\n"
. 'Vertretung: ' . ($vertretung ?: '—') . "\n"
. 'Nachricht: ' . ($nachricht ?: '—') . "\n\n"
. "---\nGesendet über OMSORG Connect"
);
header('Location: ' . $back . '?success=1');
exit;
@@ -0,0 +1,63 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/mail.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/benefitsantrag.php');
exit;
}
$back = '../pages/benefitsantrag.php';
$benefit = trim($_POST['benefit'] ?? '');
$nachricht = trim($_POST['nachricht'] ?? '');
$allowed_benefits = ['yoga', 'autogenes_training', 'massage', 'tankgutschein', 'online_gutscheine'];
if (!in_array($benefit, $allowed_benefits, true)) {
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
header('Content-Type: application/json');
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Bitte einen Benefit auswählen.']);
exit;
}
header('Location: ' . $back . '?error=' . urlencode('Bitte einen Benefit auswählen.'));
exit;
}
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO requests_benefitsantrag (user_id, status, admin_note, created_at, updated_at, benefit, nachricht)
VALUES (?, \'pending\', \'\', ?, ?, ?, ?)'
)->execute([$user_id, $now, $now, $benefit, $nachricht]);
$benefit_labels = [
'yoga' => 'Yoga',
'autogenes_training' => 'Autogenes Training',
'massage' => 'Massage',
'tankgutschein' => 'Tankgutschein',
'online_gutscheine' => 'Online-Gutscheine',
];
$config = require __DIR__ . '/../lib/config.php';
smtp_send(
$config,
$config['mail_info'],
'Neuer Benefitsantrag: ' . current_name(),
"Ein Benefitsantrag wurde eingereicht.\n\n"
. 'Mitarbeiter: ' . current_name() . "\n"
. 'Benefit: ' . ($benefit_labels[$benefit] ?? $benefit) . "\n"
. 'Nachricht: ' . ($nachricht ?: '—') . "\n\n"
. "---\nGesendet über OMSORG Connect"
);
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') {
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'benefit' => $benefit]);
exit;
}
header('Location: ' . $back . '?success=1');
exit;
@@ -0,0 +1,54 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/einsatzbewertung.php');
exit;
}
$back = '../pages/einsatzbewertung.php';
$einsatzort = trim($_POST['einsatzort'] ?? '');
$von = trim($_POST['von'] ?? '');
$bis = trim($_POST['bis'] ?? '');
$bewertung = (int)($_POST['bewertung'] ?? 0);
$wieder = isset($_POST['wieder']) ? 1 : 0;
$feedback = trim($_POST['feedback'] ?? '');
if ($einsatzort === '') {
header('Location: ' . $back . '?error=' . urlencode('Bitte den Einsatzort angeben.'));
exit;
}
if ($von === '' || $bis === '') {
header('Location: ' . $back . '?error=' . urlencode('Von und Bis sind Pflichtfelder.'));
exit;
}
if (!strtotime($von) || !strtotime($bis)) {
header('Location: ' . $back . '?error=' . urlencode('Ungültiges Datum.'));
exit;
}
if (strtotime($von) > strtotime($bis)) {
header('Location: ' . $back . '?error=' . urlencode('Das Startdatum muss vor dem Enddatum liegen.'));
exit;
}
if ($bewertung < 1 || $bewertung > 5) {
header('Location: ' . $back . '?error=' . urlencode('Bitte eine Bewertung von 1 bis 5 Sternen auswählen.'));
exit;
}
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO einsatzbewertungen (user_id, einsatzort, von, bis, bewertung, wieder, feedback, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)->execute([$user_id, $einsatzort, $von, $bis, $bewertung, $wieder, $feedback, $now]);
header('Location: ' . $back . '?success=1');
exit;
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_once __DIR__ . '/../lib/mail.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/fortbildungsantrag.php');
exit;
}
$back = '../pages/fortbildungsantrag.php';
$anliegen = trim($_POST['anliegen'] ?? '');
$thema = trim($_POST['thema'] ?? '');
$nachricht = trim($_POST['nachricht'] ?? '');
$allowed_anliegen = ['wbl_pdl', 'fortbildung', 'pflichtschulung'];
if (!in_array($anliegen, $allowed_anliegen, true)) {
redirect_error($back, 'Bitte ein Anliegen auswählen.');
}
if ($thema === '') {
redirect_error($back, 'Bitte ein Thema angeben.');
}
$up = handle_upload($_FILES['datei'] ?? [], [
'allowed' => UPLOAD_TYPES_DOCS,
'dir' => dirname(__DIR__) . '/uploads/',
'prefix' => 'fortbildung',
'required' => false,
]);
if (!$up['ok']) {
redirect_error($back, $up['error']);
}
$filename = $up['filename'] ?? '';
$original_name = $up['original_name'] ?? '';
$mime = $up['mime'] ?? null;
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO requests_fortbildungsantrag
(user_id, status, admin_note, anliegen, thema, nachricht, filename, original_name, created_at, updated_at)
VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?, ?)'
)->execute([$user_id, $anliegen, $thema, $nachricht, $filename, $original_name, $now, $now]);
$anliegen_labels = [
'wbl_pdl' => 'WBL / PDL',
'fortbildung' => 'Fortbildung',
'pflichtschulung' => 'Pflichtschulung',
];
$config = require __DIR__ . '/../lib/config.php';
$attach = ($filename !== '')
? ['path' => dirname(__DIR__) . '/uploads/' . $filename, 'name' => $original_name, 'mime' => $mime]
: [];
smtp_send(
$config,
$config['mail_info'],
'Neuer Fortbildungsantrag: ' . current_name(),
"Ein Fortbildungsantrag wurde eingereicht.\n\n"
. 'Mitarbeiter: ' . current_name() . "\n"
. 'Anliegen: ' . ($anliegen_labels[$anliegen] ?? $anliegen) . "\n"
. 'Thema: ' . $thema . "\n"
. 'Nachricht: ' . ($nachricht ?: '—') . "\n\n"
. "---\nGesendet über OMSORG Connect",
$attach
);
redirect_ok($back);
@@ -0,0 +1,63 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_once __DIR__ . '/../lib/mail.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/stundennachweis.php');
exit;
}
$back = '../pages/stundennachweis.php';
$monat_m = trim($_POST['monat_m'] ?? '');
$monat_j = trim($_POST['monat_j'] ?? '');
if (!preg_match('/^\d{2}$/', $monat_m) || !preg_match('/^\d{4}$/', $monat_j)
|| (int)$monat_m < 1 || (int)$monat_m > 12
|| (int)$monat_j < 2020 || (int)$monat_j > 2099) {
redirect_error($back, 'Bitte einen gültigen Monat und ein gültiges Jahr auswählen.');
}
$monat = $monat_j . '-' . $monat_m;
$dir = dirname(__DIR__) . '/uploads/';
$up = handle_upload($_FILES['datei'] ?? [], [
'allowed' => UPLOAD_TYPES_PDF_IMG,
'dir' => $dir,
'prefix' => 'stundennachweis',
'messages' => [
'missing' => 'Bitte eine Datei hochladen.',
'ext' => 'Dateityp nicht erlaubt. Erlaubt: PDF, JPG, PNG.',
],
]);
if (!$up['ok']) {
redirect_error($back, $up['error']);
}
$safe_name = $up['filename'];
$original_name = $up['original_name'];
$mime = $up['mime'];
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO requests_stundennachweis
(user_id, status, admin_note, monat, filename, original_name, created_at, updated_at)
VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?)'
)->execute([$user_id, $monat, $safe_name, $original_name, $now, $now]);
$config = require __DIR__ . '/../lib/config.php';
smtp_send(
$config,
$config['mail_sabrina'],
'Neuer Stundennachweis: ' . current_name(),
"Ein Stundennachweis wurde hochgeladen.\n\n"
. 'Mitarbeiter: ' . current_name() . "\n"
. 'Monat: ' . $monat . "\n"
. 'Datei: ' . $original_name . "\n\n"
. "---\nGesendet über OMSORG Connect",
['path' => $dir . $safe_name, 'name' => $original_name, 'mime' => $mime]
);
redirect_ok($back);
@@ -0,0 +1,56 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/mail.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/urlaubsantrag.php');
exit;
}
$back = '../pages/urlaubsantrag.php';
$von = trim($_POST['von'] ?? '');
$bis = trim($_POST['bis'] ?? '');
$vertretung = trim($_POST['vertretung'] ?? '');
$nachricht = trim($_POST['nachricht'] ?? '');
if ($von === '' || $bis === '') {
header('Location: ' . $back . '?error=' . urlencode('Von und Bis sind Pflichtfelder.'));
exit;
}
if (!strtotime($von) || !strtotime($bis)) {
header('Location: ' . $back . '?error=' . urlencode('Ungültiges Datum.'));
exit;
}
if (strtotime($von) > strtotime($bis)) {
header('Location: ' . $back . '?error=' . urlencode('Das Startdatum muss vor dem Enddatum liegen.'));
exit;
}
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO requests_urlaubsantrag (user_id, status, admin_note, created_at, updated_at, von, bis, vertretung, nachricht)
VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?)'
)->execute([$user_id, $now, $now, $von, $bis, $vertretung, $nachricht]);
$config = require __DIR__ . '/../lib/config.php';
smtp_send(
$config,
$config['mail_sabrina'],
'Neuer Urlaubsantrag: ' . current_name(),
"Ein Urlaubsantrag wurde eingereicht.\n\n"
. 'Mitarbeiter: ' . current_name() . "\n"
. 'Von: ' . $von . "\n"
. 'Bis: ' . $bis . "\n"
. 'Vertretung: ' . ($vertretung ?: '—') . "\n"
. 'Nachricht: ' . ($nachricht ?: '—') . "\n\n"
. "---\nGesendet über OMSORG Connect"
);
header('Location: ' . $back . '?success=1');
exit;
+62
View File
@@ -0,0 +1,62 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/mail.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/werben.php');
exit;
}
$back = '../pages/werben.php';
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$qualifikation = trim($_POST['qualifikation'] ?? '');
$nachricht = trim($_POST['nachricht'] ?? '');
if ($name === '' || $email === '' || $qualifikation === '') {
header('Location: ' . $back . '?error=' . urlencode('Name, E-Mail und Qualifikation sind Pflichtfelder.'));
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
header('Location: ' . $back . '?error=' . urlencode('Bitte eine gültige E-Mail-Adresse eingeben.'));
exit;
}
$allowed_qual = ['1jahr', '3jahr'];
if (!in_array($qualifikation, $allowed_qual, true)) {
header('Location: ' . $back . '?error=' . urlencode('Ungültige Qualifikation.'));
exit;
}
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO requests_werben (user_id, status, admin_note, created_at, updated_at, name, email, qualifikation, nachricht)
VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?)'
)->execute([$user_id, $now, $now, $name, $email, $qualifikation, $nachricht]);
$qual_labels = [
'1jahr' => '1 Jährige Examinierte Pflegekraft',
'3jahr' => '3 Jährige Examinierte Pflegekraft',
];
$config = require __DIR__ . '/../lib/config.php';
smtp_send(
$config,
$config['mail_info'],
'Mitarbeiter werben: ' . current_name(),
"Ein Mitarbeiter wurde empfohlen.\n\n"
. 'Empfohlen von: ' . current_name() . "\n"
. 'Name der empfohlenen Person: ' . $name . "\n"
. 'E-Mail: ' . $email . "\n"
. 'Art der Stelle: ' . ($qual_labels[$qualifikation] ?? $qualifikation) . "\n"
. 'Nachricht: ' . ($nachricht ?: '—') . "\n\n"
. "---\nGesendet über OMSORG Connect"
);
header('Location: ' . $back . '?success=1');
exit;
+58
View File
@@ -0,0 +1,58 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/settings.php');
exit;
}
$back = '../pages/settings.php';
$username = current_username();
$user = current_user();
$dir = dirname(__DIR__) . '/assets/avatars/';
// Remove avatar
if (!empty($_POST['remove_avatar'])) {
if (!empty($user['avatar'])) {
$old = $dir . basename($user['avatar']);
if (is_file($old)) {
unlink($old);
}
}
db()->prepare('UPDATE users SET avatar = NULL WHERE username = ?')->execute([$username]);
redirect_ok($back, 'avatar_saved');
}
$up = handle_upload($_FILES['avatar'] ?? [], [
'allowed' => UPLOAD_TYPES_IMAGES,
'dir' => $dir,
'naming' => 'plain',
'max_bytes' => 3 * 1024 * 1024,
'messages' => [
'missing' => 'Bitte ein Bild auswählen.',
'upload' => 'Fehler beim Hochladen des Bildes.',
'ext' => 'Nur Bilder erlaubt (JPG, PNG, GIF, WebP).',
'mime' => 'Nur Bilder erlaubt (JPG, PNG, GIF, WebP).',
'size' => 'Bild ist zu groß (max. 3 MB).',
'move' => 'Fehler beim Speichern des Bildes.',
],
]);
if (!$up['ok']) {
redirect_error($back, $up['error']);
}
$safe_name = $up['filename'];
// Delete old avatar after successful upload
if (!empty($user['avatar'])) {
$old = $dir . basename($user['avatar']);
if (is_file($old)) {
unlink($old);
}
}
db()->prepare('UPDATE users SET avatar = ? WHERE username = ?')->execute([$safe_name, $username]);
redirect_ok($back, 'avatar_saved');
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/upload.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: ../pages/dokumentenarchiv.php');
exit;
}
$back = '../pages/dokumentenarchiv.php';
$allowed_kategorien = ['Fortbildungsnachweis', 'Zeugnis', 'Bescheinigung', 'Sonstiges'];
$kategorie = trim($_POST['kategorie'] ?? '');
$beschreibung = trim($_POST['beschreibung'] ?? '');
if (!in_array($kategorie, $allowed_kategorien, true)) {
redirect_error($back, 'Bitte eine Kategorie auswählen.');
}
$filesize = $_FILES['datei']['size'] ?? 0;
$up = handle_upload($_FILES['datei'] ?? [], [
'allowed' => UPLOAD_TYPES_DOCS,
'dir' => dirname(__DIR__) . '/uploads/',
'prefix' => 'dokument',
]);
if (!$up['ok']) {
redirect_error($back, $up['error']);
}
$safe_name = $up['filename'];
$original_name = $up['original_name'];
$user_id = current_user()['id'];
$now = date('c');
db()->prepare(
'INSERT INTO dokumente (user_id, kategorie, beschreibung, filename, original_name, filesize, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)'
)->execute([$user_id, $kategorie, $beschreibung, $safe_name, $original_name, $filesize, $now]);
redirect_ok($back, 'ok');
+381
View File
@@ -0,0 +1,381 @@
/* ── Fonts ──────────────────────────────────────────────────────────── */
@font-face{font-family:'Visby';src:url('font/visby/VisbyThin.otf') format('opentype');font-weight:100;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyThin Italic.otf') format('opentype');font-weight:100;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyLight.otf') format('opentype');font-weight:300;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyLight Italic.otf') format('opentype');font-weight:300;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyRegular.otf') format('opentype');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyItalic.otf') format('opentype');font-weight:400;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyMedium.otf') format('opentype');font-weight:500;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyMedium Italic.otf') format('opentype');font-weight:500;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbySemibold.otf') format('opentype');font-weight:600;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbySemibold Italic.otf') format('opentype');font-weight:600;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyBold.otf') format('opentype');font-weight:700;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyBold Italic.otf') format('opentype');font-weight:700;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyExtrabold.otf') format('opentype');font-weight:800;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyExtrabold Italic.otf') format('opentype');font-weight:800;font-style:italic;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyHeavy.otf') format('opentype');font-weight:900;font-style:normal;font-display:swap}
@font-face{font-family:'Visby';src:url('font/visby/VisbyHeavy Italic.otf') format('opentype');font-weight:900;font-style:italic;font-display:swap}
@font-face{font-family:'KindelSerif';src:url('font/kindelserif/KindelSerif_PersonalUse.otf') format('opentype');font-weight:400;font-style:normal;font-display:swap}
/* ── Design tokens (identical to main app) ─────────────────────────── */
:root{
--cyan:#10d7e6;--cyan2:#36f2ff;--dark:#031522;
--glass:rgba(0,35,51,.74);--line:rgba(170,240,250,.42);
--text:#f7ffff;--muted:#d7f6f8;
--sidebar-w:240px;
}
/* ── Reset & base ───────────────────────────────────────────────────── */
*{box-sizing:border-box}
html{scroll-behavior:smooth;background:#031522;overflow-x:hidden}
body{
margin:0;min-height:100vh;color:var(--text);
font-family:'Visby',Arial,Helvetica,sans-serif;line-height:1.45;
background:#031522 url('assets/omsorg-bg-full.png') center top/cover fixed no-repeat;
overflow-x:hidden;
}
body::before{
content:"";position:fixed;inset:0;z-index:-2;
background:linear-gradient(180deg,rgba(0,9,23,.08),rgba(0,9,23,.28) 58%,rgba(0,9,23,.52));
pointer-events:none;
}
body::after{
content:"";position:fixed;inset:0;z-index:-1;opacity:.35;
background-image:radial-gradient(circle,rgba(0,216,230,.75) 0 2px,transparent 3px);
background-size:24px 24px;
mask-image:radial-gradient(circle at 0% 34%,#000 0 0,transparent 18%),
radial-gradient(circle at 100% 88%,#000 0 0,transparent 17%);
pointer-events:none;
}
a{color:inherit;text-decoration:none}
img{display:block;max-width:100%}
/* ── Glass card ─────────────────────────────────────────────────────── */
.glass{
border:1px solid var(--line);border-radius:22px;
background:linear-gradient(145deg,rgba(0,35,51,.78),rgba(8,105,122,.28));
box-shadow:0 22px 70px rgba(0,0,0,.28);
backdrop-filter:blur(16px);
}
/* ── Buttons ────────────────────────────────────────────────────────── */
.btn{
display:inline-flex;align-items:center;justify-content:center;
gap:10px;border:1px solid rgba(255,255,255,.24);border-radius:13px;
background:linear-gradient(135deg,#25e2e9,#079aaa);
color:#fff;font-weight:900;padding:14px 18px;cursor:pointer;
box-shadow:0 14px 38px rgba(0,215,230,.18);font:inherit;
}
/* ── Form elements ──────────────────────────────────────────────────── */
form{display:grid;gap:14px}
label{display:grid;gap:7px;font-weight:800;color:#fff}
input,select,textarea{
width:100%;border:1px solid rgba(170,240,250,.36);border-radius:12px;
padding:14px;background:rgba(2,11,22,.54);color:#fff;font:inherit;
}
/* ── Alerts ─────────────────────────────────────────────────────────── */
.error{
padding:12px 14px;border:1px solid rgba(255,130,130,.45);
border-radius:12px;background:rgba(255,0,0,.13);color:#fff;
}
.success{
padding:12px 14px;border:1px solid rgba(54,242,255,.45);
border-radius:12px;background:rgba(16,215,230,.1);color:#fff;
}
.hint{color:var(--muted);font-size:.9rem}
.settings-section{margin-top:36px;padding-top:28px;border-top:1px solid rgba(170,240,250,.18)}
.settings-avatar-preview{width:80px;height:80px;border-radius:50%;object-fit:cover;border:2px solid rgba(170,240,250,.3)}
.settings-avatar-placeholder{width:80px;height:80px;border-radius:50%;display:grid;place-items:center;background:linear-gradient(135deg,#20e1ea,#078fa3);font-weight:900;font-size:2rem;color:#fff;flex-shrink:0}
/* ── Login page ─────────────────────────────────────────────────────── */
.login-page{display:grid;place-items:center;min-height:100vh;padding:26px}
.login-card{width:min(100%,450px);padding:30px}
.nav-toggle{display:none}
.nav-backdrop{display:none;position:fixed;inset:0;z-index:39;background:rgba(0,0,0,.55)}
/* ── Dashboard layout ───────────────────────────────────────────────── */
.dashboard-layout{
display:grid;
grid-template-columns:1fr;
padding-left:var(--sidebar-w);
min-height:100vh;
}
/* ── Sidebar ────────────────────────────────────────────────────────── */
.sidebar{
position:fixed;top:0;left:0;width:var(--sidebar-w);height:100vh;overflow-y:auto;
display:flex;flex-direction:column;gap:0;
border-radius:0;border-top:none;border-bottom:none;border-left:none;
padding:0;
}
.sidebar-brand{
padding:22px 20px 16px;
border-bottom:1px solid rgba(170,240,250,.18);
}
.sidebar-brand img{width:160px;height:auto}
.sidebar-app-name{
display:block;margin-top:6px;
color:var(--cyan2);text-transform:uppercase;
letter-spacing:.16em;font-size:.74rem;font-weight:900;
}
.sidebar-user{
display:grid;grid-template-columns:40px 1fr;gap:12px;
align-items:center;padding:16px 20px;
border-bottom:1px solid rgba(170,240,250,.12);
}
.sidebar-avatar{
width:40px;height:40px;display:grid;place-items:center;
border-radius:50%;background:linear-gradient(135deg,#20e1ea,#078fa3);
font-weight:900;font-size:1.1rem;color:#fff;
}
.sidebar-avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover}
.sidebar-user b{display:block;font-size:.92rem}
.sidebar-user small{display:block;color:var(--muted);font-size:.78rem;margin-top:2px}
.sidebar-nav{padding:16px 12px 0;flex:1}
.sidebar-nav-label{
margin:0 8px 8px;color:var(--cyan2);
text-transform:uppercase;letter-spacing:.12em;font-size:.7rem;font-weight:900;
}
.sidebar-nav a{
display:flex;align-items:center;gap:10px;
border-radius:13px;padding:11px 12px;
color:#ebfdff;font-weight:800;font-size:.92rem;
border:1px solid transparent;margin-bottom:4px;
transition:background .15s,border-color .15s;
}
.sidebar-nav a:hover{
background:rgba(255,255,255,.075);
border-color:rgba(170,240,250,.18);
}
.sidebar-nav a.active{
background:linear-gradient(135deg,rgba(32,225,234,.22),rgba(7,143,163,.18));
border-color:rgba(54,242,255,.45);
color:#fff;
}
.nav-icon{font-size:1rem;opacity:.85}
.sidebar-footer{
padding:16px 20px;
border-top:1px solid rgba(170,240,250,.12);
margin-top:auto;
}
.sidebar-logout{
display:block;text-align:center;
border:1px solid rgba(170,240,250,.28);
border-radius:13px;padding:11px;
background:rgba(255,255,255,.06);
font-weight:900;font-size:.88rem;
transition:background .15s;
}
.sidebar-logout:hover{background:rgba(255,255,255,.12)}
/* ── Main content ───────────────────────────────────────────────────── */
.main-content{
overflow-y:auto;padding:48px 40px;min-height:100vh;
}
.main-inner{max-width:860px}
.main-inner:has(.page-split){max-width:none}
.main-inner:has(.dp-grid){max-width:none}
.main-title{
font-family:'KindelSerif',Georgia,serif;font-size:2.8rem;
line-height:1;margin:0 0 12px;color:#fff;
}
.main-title span{color:var(--cyan)}
.main-sub{color:var(--muted);font-size:1.05rem;margin:0}
/* ── Requests ───────────────────────────────────────────────────────── */
.badge{display:inline-block;padding:3px 10px;border-radius:8px;font-size:.82rem;font-weight:800}
.badge-pending {background:rgba(255,200,0,.18);border:1px solid rgba(255,200,0,.6);color:#ffe566}
.badge-accepted{background:rgba(16,215,230,.15);border:1px solid rgba(54,242,255,.55);color:var(--cyan2)}
.badge-rejected{background:rgba(255,60,60,.15);border:1px solid rgba(255,100,100,.55);color:#ff8080}
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
.monat-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.requests-table{width:100%;border-collapse:collapse;margin-top:20px}
.requests-table th{
text-align:left;padding:10px 12px;color:var(--cyan2);
font-size:.78rem;text-transform:uppercase;letter-spacing:.08em;
border-bottom:1px solid rgba(170,240,250,.2);font-weight:900;
}
.requests-table td{padding:12px;border-bottom:1px solid rgba(170,240,250,.08);vertical-align:middle}
.requests-table tr:hover td{background:rgba(255,255,255,.03)}
.type-tabs{display:flex;gap:8px;flex-wrap:wrap;margin:24px 0 0}
.type-tab{
padding:9px 18px;border-radius:11px;border:1px solid rgba(170,240,250,.22);
background:rgba(255,255,255,.05);color:#ebfdff;font-weight:800;font-size:.9rem;
cursor:pointer;transition:background .15s,border-color .15s;
}
.type-tab:hover{background:rgba(255,255,255,.1);border-color:rgba(170,240,250,.4)}
.type-tab.active{
background:linear-gradient(135deg,rgba(32,225,234,.22),rgba(7,143,163,.18));
border-color:rgba(54,242,255,.45);color:#fff;
}
.status-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}
.status-tab{
padding:8px 16px;border-radius:10px;border:1px solid rgba(170,240,250,.2);
background:rgba(255,255,255,.05);color:#ebfdff;font-weight:800;font-size:.88rem;
transition:background .15s,border-color .15s;
}
.status-tab:hover{background:rgba(255,255,255,.1)}
.status-tab.active{
background:rgba(16,215,230,.18);border-color:rgba(54,242,255,.55);color:#fff;
}
.detail-card{padding:28px;margin-top:24px}
.detail-field{margin-bottom:16px}
.detail-field-label{font-size:.8rem;text-transform:uppercase;letter-spacing:.1em;color:var(--cyan2);font-weight:900;margin-bottom:4px}
.detail-field-value{color:#fff;font-size:.97rem}
.btn-danger{background:linear-gradient(135deg,#e94560,#a01a2e)}
.btn-ghost{background:transparent;border:1px solid rgba(170,240,250,.35);color:#cff;box-shadow:none}
/* ── Split layout (list left, form right) ───────────────────────────── */
.page-split{
display:grid;
grid-template-columns:1fr 400px;
gap:32px;
align-items:start;
margin-top:28px;
}
.page-split-list h2{
font-family:'KindelSerif',Georgia,serif;font-size:1.3rem;margin:0 0 16px;color:#fff;
}
.page-split-form h2{
font-family:'KindelSerif',Georgia,serif;font-size:1.3rem;margin:0 0 20px;
}
/* ── Responsive ─────────────────────────────────────────────────────── */
@media(max-width:860px){
/* Body-Offset für fixierten Header */
body{padding-top:54px}
.dashboard-layout{grid-template-columns:1fr;padding-left:0;width:100%;max-width:100%;overflow-x:hidden}
/* Sidebar = fixierter kompakter Header, 54px hoch */
.sidebar{
position:fixed;top:0;left:0;right:0;z-index:40;
height:54px;overflow:hidden;
flex-direction:column;gap:0;
border-radius:0;border-right:none;
border-bottom:1px solid var(--line);
}
/* Brand-Zeile: Logo + Hamburger-Button */
.sidebar-brand{
display:flex;align-items:center;
height:54px;flex-shrink:0;
padding:0 14px;
border-bottom:none;
}
.sidebar-brand img{width:80px}
.sidebar-app-name{display:none}
/* Hamburger-Button */
.nav-toggle{
display:flex;align-items:center;justify-content:center;
margin-left:auto;flex-shrink:0;
width:44px;height:44px;
border:1px solid var(--line);border-radius:12px;
background:rgba(255,255,255,.08);
color:#fff;font-size:1.3rem;cursor:pointer;
transition:background .15s;
}
.nav-toggle:hover{background:rgba(255,255,255,.15)}
/* Nutzerblock + Nav + Footer: standardmäßig versteckt */
.sidebar-user{display:none}
.sidebar-nav{display:none;padding:12px 12px 0;flex-direction:column}
.sidebar-nav-label{display:block}
.sidebar-footer{display:none}
.nav-label{display:inline}
/* Geöffnet: Vollbild-Overlay */
body.nav-open .sidebar{height:100vh;overflow-y:auto}
body.nav-open .sidebar-user{display:grid}
body.nav-open .sidebar-nav{display:flex}
body.nav-open .sidebar-footer{display:block}
body.nav-open .nav-backdrop{display:block}
.main-content{padding:20px 16px;min-height:auto;width:100%;max-width:100%;overflow-x:hidden}
.main-inner{width:100%;max-width:100%}
.main-title{font-size:1.9rem}
.page-split{grid-template-columns:1fr}
.page-split-list,.page-split-form{min-width:0;width:100%}
.page-split-form{order:-1}
.requests-table,.users-table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}
}
/* ── Downloads page ─────────────────────────────────────────────────── */
.downloads-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;margin-top:28px}
.download-card{padding:24px;display:flex;flex-direction:column;gap:14px}
.download-card-title{font-family:'KindelSerif',Georgia,serif;font-size:1.15rem;margin:0;color:#fff}
.download-card-desc{color:var(--muted);font-size:.92rem;margin:0;flex:1}
.download-btn{align-self:flex-start;padding:10px 18px;font-size:.9rem}
/* ── Downloads admin list ─────────────────────────────────────────────── */
.dl-sort-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:12px;border:1px solid rgba(170,240,250,.14);background:rgba(0,35,51,.44);margin-bottom:8px;cursor:grab;transition:background .15s}
.dl-sort-item:active{cursor:grabbing}
.dl-sort-item:hover{background:rgba(0,35,51,.7)}
.dl-sort-item.drag-over{border-color:rgba(54,242,255,.55);background:rgba(16,215,230,.08)}
.dl-drag-handle{color:var(--muted);font-size:1.2rem;user-select:none;flex-shrink:0;line-height:1}
.dl-item-info{flex:1;min-width:0}
.dl-item-title{font-weight:800;font-size:.93rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dl-item-filename{color:var(--muted);font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
/* ── Dienstplan calendar ──────────────────────────────────────────────── */
.dp-ort{margin:0 0 20px;font-size:.95rem;color:var(--muted)}
.dp-ort strong{color:#ebfdff}
.dp-nav{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:12px}
.dp-nav-title{font-family:'KindelSerif',Georgia,serif;font-size:1.6rem;color:#fff;margin:0}
.dp-nav-btn{padding:8px 18px;border-radius:10px;border:1px solid rgba(170,240,250,.3);background:rgba(255,255,255,.06);color:#ebfdff;font-weight:800;font-size:.88rem;cursor:pointer;text-decoration:none;transition:background .15s}
.dp-nav-btn:hover{background:rgba(255,255,255,.12)}
.dp-save-bar{display:flex;justify-content:flex-end;align-items:center;gap:16px;margin-bottom:20px}
.dp-grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.dp-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px;min-width:480px}
.dp-weekday{text-align:center;padding:8px 4px;color:var(--cyan2);font-size:.72rem;text-transform:uppercase;letter-spacing:.1em;font-weight:900}
.dp-cell{min-height:80px;border-radius:12px;padding:8px;border:1px solid rgba(170,240,250,.14);background:rgba(0,35,51,.44);display:flex;flex-direction:column;gap:6px}
.dp-cell-empty{background:transparent;border-color:transparent;min-height:0}
.dp-cell-today{border-color:rgba(54,242,255,.55);background:rgba(16,215,230,.08)}
.dp-cell-frueh{background:rgba(54,242,100,.1);border-color:rgba(54,242,100,.38)}
.dp-cell-spaet{background:rgba(255,180,50,.1);border-color:rgba(255,180,50,.38)}
.dp-cell-nacht{background:rgba(130,80,255,.14);border-color:rgba(130,80,255,.42)}
.dp-day-num{font-size:.78rem;font-weight:900;color:var(--muted);line-height:1}
.dp-cell-today .dp-day-num{color:var(--cyan2)}
.dp-select{width:100%;border:1px solid rgba(170,240,250,.28);border-radius:8px;padding:5px 6px;background:rgba(2,11,22,.62);color:#fff;font:inherit;font-size:.8rem;cursor:pointer}
.dp-badge{display:block;padding:3px 8px;border-radius:7px;font-size:.75rem;font-weight:900;text-align:center;word-break:break-word}
.dp-badge-frueh{background:rgba(54,242,100,.15);border:1px solid rgba(54,242,100,.5);color:#6eff9a}
.dp-badge-spaet{background:rgba(255,180,50,.15);border:1px solid rgba(255,180,50,.5);color:#ffc84a}
.dp-badge-nacht{background:rgba(130,80,255,.18);border:1px solid rgba(130,80,255,.5);color:#b89cff}
.dp-overlay-urlaub{background:rgba(16,215,230,.18);border:1px solid rgba(54,242,255,.5);color:var(--cyan2)}
.dp-overlay-abwesenheit{background:rgba(255,200,0,.15);border:1px solid rgba(255,200,0,.5);color:#ffe566}
.dp-user-select-wrap{margin-bottom:28px}
.dp-user-select-wrap select{max-width:320px;border-radius:12px;padding:12px;font-weight:800}
.dp-free{font-size:.75rem;color:rgba(215,246,248,.35)}
@media(max-width:680px){
.dp-grid{gap:3px}
.dp-cell{min-height:60px;padding:5px}
.dp-weekday{font-size:.62rem}
.dp-day-num{font-size:.7rem}
.dp-select{font-size:.72rem;padding:4px}
}
@media(max-width:480px){
.monat-grid{grid-template-columns:1fr}
.main-content{padding:18px 14px}
.main-title{font-size:1.6rem}
.btn-sm{padding:11px 13px}
.detail-card{padding:16px}
.modal-box{padding:20px 16px}
.admin-tabs{flex-wrap:wrap;gap:6px;margin-bottom:20px}
.admin-tab{padding:8px 14px;font-size:.82rem}
.type-tab,.status-tab{padding:7px 12px;font-size:.82rem}
.dp-cell{min-height:44px;padding:4px 3px}
.dp-weekday{font-size:.58rem;padding:6px 2px}
.dp-badge{font-size:.65rem;padding:2px 4px}
.dp-select{font-size:.68rem;padding:3px 2px}
.requests-table,.users-table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}
.login-card{padding:22px 18px}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,38 @@
1001Fonts Free For Personal Use License (FFP)
Preamble
In this license, 'KindelSerif' refers to the given .zip file, which may contain one or numerous fonts. These fonts can be of any type (.ttf, .otf, ...) and together they form a 'font family' or in short a 'typeface'.
1. Copyright
KindelSerif is the intellectual property of its respective author, provided it is original, and is protected by copyright laws in many parts of the world.
2. Personal Use
KindelSerif may be downloaded and used free of charge for personal use, as long as the usage is not racist or illegal. Personal use refers to all usage that does not generate financial income in a business manner, for instance:
- personal scrapbooking for yourself
- recreational websites and blogs for friends and family
- prints such as flyers, posters, t-shirts for churches, charities, and non-profit organizations
3. Commercial Use
Commercial use is not allowed without prior written permission from the respective author. Please contact the author to ask for commercial licensing. Commercial use refers to usage in a business environment, including:
- business cards, logos, advertising, websites, mobile apps for companies
- t-shirts, books, apparel that will be sold for money
- flyers, posters for events that charge admission
- freelance graphic design work
- anything that will generate direct or indirect income
4. Modification
KindelSerif may not be modified, altered, adapted or built upon without written permission by its respective author. This pertains all files within the downloadable font zip-file.
5. Conversion
KindelSerif may be converted to other formats such as WOFF, SVG or EOT webfonts, as long as the font is not modified in any other way, such as changing names or altering individual glyphs.
6. Distribution
While KindelSerif may freely be copied and passed along to other individuals for private use as its original downloadable zip-file, it may not be sold or published without written permission by its respective author.
7. Embedding
KindelSerif may be embedded into an application such as a web- or mobile app, as long as the application is of personal use and does not distribute KindelSerif, such as offering it as a download.
8. Disclaimer
KindelSerif is offered 'as is' without any warranty. 1001fonts.com and the respective author of KindelSerif shall not be liable for any damage derived from using this typeface. By using KindelSerif you agree to the terms of this license.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
<?php
require_once __DIR__ . '/lib/auth.php';
if (is_logged_in()) {
header('Location: pages/dashboard.php');
exit;
}
$error = '';
$locked = false;
$MAX_FAILS = 5;
$LOCKOUT_SEC = 600;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$fails = login_recent_fails($ip, $LOCKOUT_SEC);
if ($fails >= $MAX_FAILS) {
$locked = true;
$error = 'Zu viele Fehlversuche. Bitte warte ca. 10 Minuten.';
}
if (!$locked && $_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if ($username !== '' && $password !== '') {
$stmt = db()->prepare(
'SELECT * FROM users WHERE LOWER(username) = LOWER(?) AND active = 1'
);
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
login_clear_fails($ip);
session_regenerate_id(true);
$_SESSION['felix_logged_in'] = true;
$_SESSION['felix_username'] = $user['username'];
$_SESSION['felix_name'] = $user['name'];
$_SESSION['felix_role'] = $user['role'];
header('Location: pages/dashboard.php');
exit;
}
}
login_record_fail($ip, $username ?? '');
$error = 'Benutzername oder Passwort falsch.';
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OMSORG Felix Login</title>
<link rel="stylesheet" href="app.css">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#1a3a5c">
</head>
<body>
<div class="login-page">
<div class="login-card glass">
<div class="brand" style="text-align:center">
<img src="assets/omsorg-wordmark-new.png" alt="OMSORG" style="width:210px;margin:0 auto 22px">
</div>
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.7rem;margin:0 0 6px">Mitarbeiter-App</h2>
<p class="hint" style="margin:0 0 22px">Melde dich mit deinem Benutzerkonto an.</p>
<?php if ($error): ?>
<div class="error" style="margin-bottom:16px"><?= e($error) ?></div>
<?php endif; ?>
<form method="post">
<?= csrf_field() ?>
<label>
Benutzername
<input type="text" name="username" autocomplete="username" required autofocus
value="<?= e($_POST['username'] ?? '') ?>">
</label>
<label>
Passwort
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="btn" style="width:100%;margin-top:6px">Anmelden</button>
</form>
</div>
</div>
<script>
if ('serviceWorker' in navigator) navigator.serviceWorker.register('service-worker.js');
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Require all denied
+111
View File
@@ -0,0 +1,111 @@
<?php
require_once __DIR__ . '/db.php';
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? null) == 443);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'secure' => $secure,
'samesite' => 'Lax',
]);
session_name('OMSORG_FELIX_APP');
session_start();
function is_logged_in(): bool {
if (empty($_SESSION['felix_logged_in']) || empty($_SESSION['felix_username'])) {
return false;
}
$stmt = db()->prepare('SELECT active FROM users WHERE username = ?');
$stmt->execute([$_SESSION['felix_username']]);
$row = $stmt->fetch();
return $row && (int)$row['active'] === 1;
}
function require_login(): void {
if (!is_logged_in()) {
header('Location: ../index.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) {
verify_csrf();
}
}
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string {
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
}
function verify_csrf(): void {
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals(csrf_token(), $token)) {
http_response_code(403);
exit('Ungültige Anfrage.');
}
}
function require_admin(): void {
require_login();
if (!is_admin()) {
header('Location: ../pages/dashboard.php');
exit;
}
}
function is_admin(): bool {
return isset($_SESSION['felix_role']) && $_SESSION['felix_role'] === 'admin';
}
function current_user(): array|false {
static $cache = null;
static $cachedFor = null;
$username = $_SESSION['felix_username'] ?? '';
if ($username === '') return false;
if ($cache !== null && $cachedFor === $username) return $cache;
$stmt = db()->prepare('SELECT * FROM users WHERE username = ?');
$stmt->execute([$username]);
$cache = $stmt->fetch();
$cachedFor = $username;
return $cache;
}
function current_username(): string {
return $_SESSION['felix_username'] ?? '';
}
function current_name(): string {
return $_SESSION['felix_name'] ?? '';
}
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// --- Login rate limiting (IP-based, persisted in DB) ---
function login_recent_fails(string $ip, int $windowSec = 600): int {
$cutoff = date('c', time() - $windowSec);
$stmt = db()->prepare(
'SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempted_at >= ?'
);
$stmt->execute([$ip, $cutoff]);
return (int) $stmt->fetchColumn();
}
function login_record_fail(string $ip, string $username): void {
db()->prepare(
'INSERT INTO login_attempts (ip, username, attempted_at) VALUES (?, ?, ?)'
)->execute([$ip, $username, date('c')]);
}
function login_clear_fails(string $ip): void {
db()->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]);
}
@@ -0,0 +1,8 @@
<?php
// Vorlage: nach config.secret.php kopieren und mit echten Werten füllen.
// config.secret.php niemals ins Repo/Backup geben.
return [
'db_user' => 'DEIN_DB_BENUTZER',
'db_password' => 'DEIN_DB_PASSWORT',
'smtp_password' => 'DEIN_SMTP_PASSWORT',
];
+90
View File
@@ -0,0 +1,90 @@
<?php
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$c = require __DIR__ . '/config.php';
$pdo = new PDO(
$c['db_dsn'],
$c['db_user'],
$c['db_password'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
if ($c['db_driver'] === 'sqlite') {
$pdo->exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;');
}
_run_migrations($pdo);
}
return $pdo;
}
function _table_exists(PDO $pdo, string $table, string $driver): bool {
if ($driver === 'sqlite') {
return (bool) $pdo->query(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=" . $pdo->quote($table)
)->fetchColumn();
}
return (bool) $pdo->query(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=" . $pdo->quote($table)
)->fetchColumn();
}
function _run_migrations(PDO $pdo): void {
$c = require __DIR__ . '/config.php';
$driver = $c['db_driver'];
$migrationTableExisted = _table_exists($pdo, 'schema_migrations', $driver);
$pdo->exec("
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) NOT NULL PRIMARY KEY,
applied_at VARCHAR(32) NOT NULL
)
");
$files = glob(__DIR__ . '/../migrations/*.php');
if (!$files) return;
sort($files);
// Existing DB without migration table: stamp all known migrations as applied.
if (!$migrationTableExisted && _table_exists($pdo, 'users', $driver)) {
$insertIgnore = $driver === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
$stmt = $pdo->prepare(
"$insertIgnore INTO schema_migrations (version, applied_at) VALUES (?, ?)"
);
foreach ($files as $file) {
$stmt->execute([basename($file, '.php'), date('c')]);
}
return;
}
$applied = $pdo->query('SELECT version FROM schema_migrations')
->fetchAll(PDO::FETCH_COLUMN);
$applied = array_flip($applied);
$useTransactions = ($driver === 'sqlite');
foreach ($files as $file) {
$version = basename($file, '.php');
if (isset($applied[$version])) continue;
$migration = require $file;
if ($useTransactions) $pdo->beginTransaction();
try {
foreach ((array) $migration['up'] as $sql) {
$pdo->exec($sql);
}
$pdo->prepare(
'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)'
)->execute([$version, date('c')]);
if ($useTransactions) $pdo->commit();
} catch (Throwable $e) {
if ($useTransactions && $pdo->inTransaction()) $pdo->rollBack();
throw new RuntimeException(
"Migration {$version} failed: " . $e->getMessage(), 0, $e
);
}
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
function layout_start(string $title, string $current_page, string $extra_head = ''): void {
$name = current_name();
$admin = is_admin();
$user = current_user();
$nav = [
'dashboard' => ['href' => 'dashboard.php', 'icon' => '⊞', 'label' => 'Dashboard'],
'stundennachweis' => ['href' => 'stundennachweis.php', 'icon' => '🕐', 'label' => 'Stundennachweis'],
'urlaubsantrag' => ['href' => 'urlaubsantrag.php', 'icon' => '🌴', 'label' => 'Urlaubsantrag'],
'dienstplan' => ['href' => 'dienstplan.php', 'icon' => '📋', 'label' => 'Dienstplan'],
'downloads' => ['href' => 'downloads.php', 'icon' => '📥', 'label' => 'Downloads'],
'einsatzanweisung' => ['href' => 'einsatzanweisung.php', 'icon' => '📄', 'label' => 'Einsatzanweisung'],
'benefitsantrag' => ['href' => 'benefitsantrag.php', 'icon' => '🎁', 'label' => 'Benefitsantrag'],
'abwesenheitsantrag' => ['href' => 'abwesenheitsantrag.php', 'icon' => '📅', 'label' => 'Abwesenheitsantrag'],
'fortbildungsantrag' => ['href' => 'fortbildungsantrag.php', 'icon' => '🎓', 'label' => 'Fortbildungsantrag'],
'dokumentenarchiv' => ['href' => 'dokumentenarchiv.php', 'icon' => '🗂', 'label' => 'Dokumentenarchiv'],
'einsatzbewertung' => ['href' => 'einsatzbewertung.php', 'icon' => '⭐', 'label' => 'Einsatzbewertung'],
'werben' => ['href' => 'werben.php', 'icon' => '🤝', 'label' => 'Mitarbeiter werben'],
'settings' => ['href' => 'settings.php', 'icon' => '⚙', 'label' => 'Einstellungen'],
];
if ($admin) {
$nav['admin'] = ['href' => 'admin.php', 'icon' => '🛡', 'label' => 'Verwaltung'];
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= e($title) ?></title>
<link rel="stylesheet" href="../app.css">
<link rel="manifest" href="../manifest.webmanifest">
<meta name="theme-color" content="#1a3a5c">
<meta name="csrf-token" content="<?= csrf_token() ?>">
<?= $extra_head ?>
</head>
<body>
<div class="dashboard-layout">
<aside class="sidebar glass">
<div class="sidebar-brand">
<img src="../assets/omsorg-wordmark-new.png" alt="OMSORG">
<span class="sidebar-app-name">Mitarbeiter-App</span>
<button class="nav-toggle" type="button" aria-label="Menü öffnen" aria-expanded="false">☰</button>
</div>
<div class="sidebar-user">
<div class="sidebar-avatar">
<?php if (!empty($user['avatar'])): ?>
<img src="../assets/avatars/<?= e($user['avatar']) ?>" alt="<?= e($name) ?>">
<?php else: ?>
<?= e(mb_substr($name, 0, 1)) ?>
<?php endif; ?>
</div>
<div>
<b><?= e($name) ?></b>
<small><?= $admin ? 'Admin' : 'Mitarbeiter' ?></small>
</div>
</div>
<nav class="sidebar-nav">
<p class="sidebar-nav-label">Navigation</p>
<?php foreach ($nav as $key => $item): ?>
<a href="<?= e($item['href']) ?>"<?= $current_page === $key ? ' class="active"' : '' ?>>
<span class="nav-icon"><?= $item['icon'] ?></span><span class="nav-label"> <?= e($item['label']) ?></span>
</a>
<?php endforeach; ?>
</nav>
<div class="sidebar-footer">
<a href="../actions/logout.php" class="sidebar-logout">Abmelden</a>
</div>
</aside>
<div class="nav-backdrop" onclick="closeNav()"></div>
<main class="main-content">
<div class="main-inner">
<?php
}
function layout_end(): void {
?>
</div>
</main>
</div>
<script>
if ('serviceWorker' in navigator) navigator.serviceWorker.register('../service-worker.js');
function closeNav(){
document.body.classList.remove('nav-open');
const btn=document.querySelector('.nav-toggle');
if(btn){btn.textContent='☰';btn.setAttribute('aria-expanded','false');}
}
const navToggle=document.querySelector('.nav-toggle');
if(navToggle){
navToggle.addEventListener('click',function(){
const open=document.body.classList.toggle('nav-open');
this.textContent=open?'✕':'☰';
this.setAttribute('aria-expanded',open);
});
}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeNav();});
</script>
</body>
</html>
<?php
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* Sends an email with an optional file attachment via SMTP.
*
* @param array $cfg Config array with smtp_host, smtp_port, smtp_user, smtp_password, mail_from
* @param string $to Recipient address
* @param string $subject Subject (plain UTF-8, will be encoded)
* @param string $body Plain-text body
* @param array $attach Optional: ['path' => '/abs/path', 'name' => 'display.pdf', 'mime' => 'application/pdf']
* @return array ['ok' => bool, 'log' => string]
*/
function smtp_send(array $cfg, string $to, string $subject, string $body, array $attach = []): array
{
$log = '';
$read = function ($conn) use (&$log) {
$line = '';
while ($chunk = fgets($conn, 512)) {
$log .= '<< ' . $chunk;
$line = $chunk;
if ($chunk[3] === ' ') break; // last line of multi-line response
}
return $line;
};
$write = function ($conn, string $cmd) use (&$log) {
$log .= '>> ' . $cmd;
fwrite($conn, $cmd);
};
$code = fn(string $line) => (int)substr($line, 0, 3);
try {
$host = trim($cfg['smtp_host']);
$port = (int)$cfg['smtp_port'];
$ssl = ($port === 465);
$conn = @fsockopen(($ssl ? 'ssl://' : '') . $host, $port, $errno, $errstr, 10);
if (!$conn) {
return ['ok' => false, 'log' => "Connect failed: $errstr ($errno)"];
}
stream_set_timeout($conn, 10);
$line = $read($conn);
if ($code($line) !== 220) throw new RuntimeException("Unexpected greeting: $line");
$write($conn, "EHLO omsorg-connect\r\n");
$capabilities = '';
while ($chunk = fgets($conn, 512)) {
$log .= '<< ' . $chunk;
$capabilities .= $chunk;
if ($chunk[3] === ' ') break;
}
if ($code($capabilities) !== 250) throw new RuntimeException("EHLO failed");
if (!$ssl && str_contains($capabilities, 'STARTTLS')) {
$write($conn, "STARTTLS\r\n");
$line = $read($conn);
if ($code($line) !== 220) throw new RuntimeException("STARTTLS failed: $line");
if (!stream_socket_enable_crypto($conn, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new RuntimeException("TLS handshake failed");
}
$write($conn, "EHLO omsorg-connect\r\n");
while ($chunk = fgets($conn, 512)) {
$log .= '<< ' . $chunk;
if ($chunk[3] === ' ') break;
}
}
$write($conn, "AUTH LOGIN\r\n");
$line = $read($conn);
if ($code($line) !== 334) throw new RuntimeException("AUTH LOGIN failed: $line");
$write($conn, base64_encode($cfg['smtp_user']) . "\r\n");
$line = $read($conn);
if ($code($line) !== 334) throw new RuntimeException("Username rejected: $line");
$write($conn, base64_encode($cfg['smtp_password']) . "\r\n");
$line = $read($conn);
if ($code($line) !== 235) throw new RuntimeException("Password rejected: $line");
$write($conn, "MAIL FROM:<{$cfg['mail_from']}>\r\n");
$line = $read($conn);
if ($code($line) !== 250) throw new RuntimeException("MAIL FROM failed: $line");
$write($conn, "RCPT TO:<$to>\r\n");
$line = $read($conn);
if ($code($line) !== 250) throw new RuntimeException("RCPT TO failed: $line");
$write($conn, "DATA\r\n");
$line = $read($conn);
if ($code($line) !== 354) throw new RuntimeException("DATA failed: $line");
$enc_subject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$boundary = 'omsorg_' . md5(uniqid((string)mt_rand(), true));
$headers = "From: OMSORG Connect <{$cfg['mail_from']}>\r\n";
$headers .= "To: $to\r\n";
$headers .= "Subject: $enc_subject\r\n";
$headers .= "MIME-Version: 1.0\r\n";
if ($attach) {
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
$msg = $headers . "\r\n";
$msg .= "--$boundary\r\n";
$msg .= "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n";
$msg .= $body . "\r\n\r\n";
$safe_name = preg_replace('/[^A-Za-z0-9._-]/', '_', $attach['name']);
$file_data = chunk_split(base64_encode(file_get_contents($attach['path'])));
$msg .= "--$boundary\r\n";
$msg .= "Content-Type: {$attach['mime']}; name=\"$safe_name\"\r\n";
$msg .= "Content-Transfer-Encoding: base64\r\n";
$msg .= "Content-Disposition: attachment; filename=\"$safe_name\"\r\n\r\n";
$msg .= $file_data . "\r\n";
$msg .= "--$boundary--";
} else {
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$msg = $headers . "\r\n" . $body;
}
// Dot-stuffing: lines starting with '.' must be doubled
$msg = str_replace("\n.", "\n..", $msg);
$write($conn, $msg . "\r\n.\r\n");
$line = $read($conn);
if ($code($line) !== 250) throw new RuntimeException("Message rejected: $line");
$write($conn, "QUIT\r\n");
$read($conn);
fclose($conn);
return ['ok' => true, 'log' => $log];
} catch (RuntimeException $e) {
if (isset($conn) && is_resource($conn)) fclose($conn);
return ['ok' => false, 'log' => $log . "\nError: " . $e->getMessage()];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
require_once __DIR__ . '/auth.php';
// Gemeinsame Endung→MIME-Whitelists.
const UPLOAD_TYPES_DOCS = [
'pdf' => ['application/pdf'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
];
const UPLOAD_TYPES_PDF_IMG = [
'pdf' => ['application/pdf'],
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
];
const UPLOAD_TYPES_IMAGES = [
'jpg' => ['image/jpeg'],
'jpeg' => ['image/jpeg'],
'png' => ['image/png'],
'gif' => ['image/gif'],
'webp' => ['image/webp'],
];
const UPLOAD_TYPES_PDF = [
'pdf' => ['application/pdf'],
];
const UPLOAD_TYPES_OFFICE = [
'pdf' => ['application/pdf'],
'doc' => ['application/msword'],
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'xls' => ['application/vnd.ms-excel'],
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
'ppt' => ['application/vnd.ms-powerpoint'],
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
'txt' => ['text/plain'],
'zip' => ['application/zip', 'application/x-zip-compressed'],
];
/**
* Validiert und speichert eine hochgeladene Datei.
*
* @param array $file Eintrag aus $_FILES, z.B. $_FILES['datei']
* @param array $opts allowed (ext=>mimes, Pflicht), dir (Pflicht), prefix,
* max_bytes (Default 12 MB), required (Default true),
* naming ('dated' [Default] = Datum_Username_Prefix_Rand,
* 'plain' = Username_Rand, 'hash' = nur Rand),
* username (Default current_username(); für 'dated'/'plain'),
* messages (Überschreibt Standardtexte: missing/upload/ext/mime/size/move)
* @return array ['ok'=>bool, 'error'=>?string, 'filename'=>?string,
* 'original_name'=>?string, 'mime'=>?string]
*/
function handle_upload(array $file, array $opts): array {
$allowed = $opts['allowed'];
$dir = rtrim($opts['dir'], '/') . '/';
$maxBytes = $opts['max_bytes'] ?? 12 * 1024 * 1024;
$required = $opts['required'] ?? true;
$naming = $opts['naming'] ?? 'dated';
$prefix = $opts['prefix'] ?? 'datei';
$username = $opts['username'] ?? current_username();
$msg = array_merge([
'missing' => 'Bitte eine Datei auswählen.',
'upload' => 'Fehler beim Hochladen der Datei.',
'ext' => 'Dateityp nicht erlaubt.',
'mime' => 'Dateityp nicht erlaubt.',
'size' => 'Datei ist zu groß (max. 12 MB).',
'move' => 'Fehler beim Hochladen der Datei.',
], $opts['messages'] ?? []);
$err = $file['error'] ?? UPLOAD_ERR_NO_FILE;
if ($err === UPLOAD_ERR_NO_FILE || !isset($file['tmp_name'])) {
if ($required) return ['ok' => false, 'error' => $msg['missing']];
return ['ok' => true, 'error' => null, 'filename' => null, 'original_name' => null, 'mime' => null];
}
if ($err !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => $msg['upload']];
}
$originalName = $file['name'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!isset($allowed[$ext])) {
return ['ok' => false, 'error' => $msg['ext']];
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $allowed[$ext], true)) {
return ['ok' => false, 'error' => $msg['mime']];
}
if ($file['size'] > $maxBytes) {
return ['ok' => false, 'error' => $msg['size']];
}
$safeName = match ($naming) {
'hash' => bin2hex(random_bytes(16)) . '.' . $ext,
'plain' => $username . '_' . bin2hex(random_bytes(8)) . '.' . $ext,
default => date('Y-m-d') . '_' . $username . '_' . $prefix . '_' . bin2hex(random_bytes(8)) . '.' . $ext,
};
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (!move_uploaded_file($file['tmp_name'], $dir . $safeName)) {
return ['ok' => false, 'error' => $msg['move']];
}
return [
'ok' => true,
'error' => null,
'filename' => $safeName,
'original_name' => $originalName,
'mime' => $mime,
];
}
function redirect_error(string $back, string $msg): never {
header('Location: ' . $back . '?error=' . urlencode($msg));
exit;
}
function redirect_ok(string $back, string $param = 'success', string $val = '1'): never {
header('Location: ' . $back . '?' . $param . '=' . $val);
exit;
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "OMSORG Mitarbeiter-App",
"short_name": "OMSORG",
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a3a5c",
"icons": [
{ "src": "../assets/omsorg-logo-new.png", "sizes": "192x192", "type": "image/png" },
{ "src": "../assets/omsorg-logo-new.png", "sizes": "512x512", "type": "image/png" }
]
}
+1
View File
@@ -0,0 +1 @@
Require all denied
@@ -0,0 +1,145 @@
<?php
return [
'description' => 'Initial schema',
'up' => [
"CREATE TABLE IF NOT EXISTS users (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'user',
password_hash VARCHAR(255) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at VARCHAR(32) NOT NULL,
email VARCHAR(255) NOT NULL DEFAULT '',
telefon VARCHAR(64) NOT NULL DEFAULT ''
)",
"CREATE TABLE IF NOT EXISTS requests_urlaubsantrag (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
admin_note TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
von VARCHAR(32) NOT NULL DEFAULT '',
bis VARCHAR(32) NOT NULL DEFAULT '',
vertretung VARCHAR(255) NOT NULL DEFAULT '',
nachricht TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS requests_abwesenheitsantrag (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
admin_note TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
von VARCHAR(32) NOT NULL DEFAULT '',
bis VARCHAR(32) NOT NULL DEFAULT '',
grund VARCHAR(64) NOT NULL DEFAULT '',
vertretung VARCHAR(255) NOT NULL DEFAULT '',
nachricht TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS requests_benefitsantrag (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
admin_note TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
benefit VARCHAR(64) NOT NULL DEFAULT '',
nachricht TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS requests_werben (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
admin_note TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
email VARCHAR(255) NOT NULL DEFAULT '',
qualifikation VARCHAR(64) NOT NULL DEFAULT '',
nachricht TEXT NOT NULL
)",
"CREATE TABLE IF NOT EXISTS dienstplan (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
date VARCHAR(16) NOT NULL,
schicht VARCHAR(16) NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
UNIQUE KEY uq_user_date (user_id, date)
)",
"CREATE TABLE IF NOT EXISTS downloads (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL
)",
"CREATE TABLE IF NOT EXISTS fortbildung_materials (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL
)",
"CREATE TABLE IF NOT EXISTS requests_fortbildungsantrag (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
admin_note TEXT NOT NULL,
anliegen TEXT NOT NULL,
thema TEXT NOT NULL,
nachricht TEXT NOT NULL,
filename VARCHAR(255) NOT NULL DEFAULT '',
original_name VARCHAR(255) NOT NULL DEFAULT '',
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL
)",
"CREATE TABLE IF NOT EXISTS requests_stundennachweis (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
admin_note TEXT NOT NULL,
monat VARCHAR(16) NOT NULL,
filename VARCHAR(255) NOT NULL DEFAULT '',
original_name VARCHAR(255) NOT NULL DEFAULT '',
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL
)",
"CREATE TABLE IF NOT EXISTS einsatzbewertungen (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
einsatzort VARCHAR(255) NOT NULL,
von VARCHAR(32) NOT NULL,
bis VARCHAR(32) NOT NULL,
bewertung TINYINT NOT NULL,
wieder TINYINT(1) NOT NULL DEFAULT 0,
feedback TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL
)",
"CREATE TABLE IF NOT EXISTS dokumente (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
kategorie VARCHAR(255) NOT NULL DEFAULT 'Sonstiges',
beschreibung TEXT NOT NULL,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
filesize INT NOT NULL DEFAULT 0,
created_at VARCHAR(32) NOT NULL
)",
"CREATE TABLE IF NOT EXISTS news (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
text TEXT NOT NULL,
date VARCHAR(16) NOT NULL,
created_at VARCHAR(32) NOT NULL
)",
],
];
@@ -0,0 +1,7 @@
<?php
return [
'description' => 'Add avatar column to users',
'up' => [
'ALTER TABLE users ADD COLUMN avatar VARCHAR(255) DEFAULT NULL',
],
];
@@ -0,0 +1,14 @@
<?php
return [
'description' => 'Einsatzanweisung table',
'up' => [
"CREATE TABLE IF NOT EXISTS einsatzanweisung (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL UNIQUE,
filename VARCHAR(255) NOT NULL DEFAULT '',
original_name VARCHAR(255) NOT NULL DEFAULT '',
ort VARCHAR(255) NOT NULL DEFAULT '',
uploaded_at VARCHAR(32) NOT NULL DEFAULT ''
)",
],
];
@@ -0,0 +1,13 @@
<?php
return [
'description' => 'Login attempts table for IP-based rate limiting',
'up' => [
"CREATE TABLE IF NOT EXISTS login_attempts (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL DEFAULT '',
username VARCHAR(190) NOT NULL DEFAULT '',
attempted_at VARCHAR(32) NOT NULL DEFAULT ''
)",
"CREATE INDEX idx_login_attempts_ip_time ON login_attempts (ip, attempted_at)",
],
];
@@ -0,0 +1,165 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM requests_abwesenheitsantrag WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$requests = $stmt->fetchAll();
$grund_labels = [
'krankheit' => 'Krankheit',
'arztbesuch' => 'Arztbesuch',
'behoerdengang' => 'Behördengang',
'sonderurlaub' => 'Sonderurlaub',
'elternzeit_pflegezeit' => 'Elternzeit / Pflegezeit',
'sonstiges' => 'Sonstiges',
];
$status_labels = ['pending' => 'Ausstehend', 'accepted' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Abwesenheitsantrag', 'abwesenheitsantrag');
?>
<style>
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:1000}
.modal-box{background:var(--glass-bg,#1e2235);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:28px 32px;max-width:480px;width:90%;position:relative}
.modal-box h3{margin:0 0 20px;font-family:'KindelSerif',Georgia,serif;font-size:1.3rem}
.modal-close{position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted,#888);line-height:1}
.modal-dl{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:.95rem}
.modal-dl dt{color:var(--muted,#888);white-space:nowrap}
.modal-dl dd{margin:0;font-weight:500}
.btn-sm{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:9px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:#fff;font-weight:600;font-size:.82rem;cursor:pointer;font:inherit;transition:background .15s}
.btn-sm:hover{background:rgba(255,255,255,.15)}
</style>
<h1 class="main-title">📅 Abwesenheitsantrag</h1>
<p class="main-sub">Melde eine geplante Abwesenheit. Das Startdatum muss vor dem Enddatum liegen.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Antrag erfolgreich eingereicht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div id="abw-modal" class="modal-backdrop" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box glass">
<button class="modal-close" onclick="document.getElementById('abw-modal').style.display='none'">&times;</button>
<h3>Antrag Details</h3>
<dl class="modal-dl">
<dt>Von</dt> <dd id="abw-von"></dd>
<dt>Bis</dt> <dd id="abw-bis"></dd>
<dt>Grund</dt> <dd id="abw-grund"></dd>
<dt>Vertretung</dt> <dd id="abw-vertretung"></dd>
<dt>Nachricht</dt> <dd id="abw-nachricht"></dd>
<dt>Eingereicht</dt><dd id="abw-eingereicht"></dd>
<dt>Status</dt> <dd id="abw-status"></dd>
<dt>Admin-Notiz</dt><dd id="abw-note"></dd>
</dl>
</div>
</div>
<div class="page-split">
<div class="page-split-list">
<h2>Meine Anträge</h2>
<?php if (empty($requests)): ?>
<p style="color:var(--muted)">Noch keine Anträge gestellt.</p>
<?php else: ?>
<table class="requests-table">
<thead>
<tr>
<th>Von</th>
<th>Bis</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?= e(date('d.m.Y', strtotime($r['von']))) ?></td>
<td><?= e(date('d.m.Y', strtotime($r['bis']))) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_labels[$r['status']] ?? $r['status']) ?></span></td>
<td>
<button class="btn-sm js-abw-details"
data-von="<?= e(date('d.m.Y', strtotime($r['von']))) ?>"
data-bis="<?= e(date('d.m.Y', strtotime($r['bis']))) ?>"
data-grund="<?= e($grund_labels[$r['grund']] ?? $r['grund']) ?>"
data-vertretung="<?= e($r['vertretung'] !== '' ? $r['vertretung'] : '') ?>"
data-nachricht="<?= e($r['nachricht'] !== '' ? $r['nachricht'] : '') ?>"
data-eingereicht="<?= e(date('d.m.Y', strtotime($r['created_at']))) ?>"
data-status-label="<?= e($status_labels[$r['status']] ?? $r['status']) ?>"
data-status-key="<?= e($r['status']) ?>"
data-note="<?= e($r['admin_note'] !== '' ? $r['admin_note'] : '') ?>"
>Details</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Neuer Abwesenheitsantrag</h2>
<form action="../actions/submit-abwesenheitsantrag.php" method="post">
<?= csrf_field() ?>
<label>
<span>Von <span style="color:#ff8080">*</span></span>
<input type="date" name="von" required>
</label>
<label>
<span>Bis <span style="color:#ff8080">*</span></span>
<input type="date" name="bis" required>
</label>
<label>
<span>Grund <span style="color:#ff8080">*</span></span>
<select name="grund" required>
<option value=""> Bitte wählen </option>
<option value="krankheit">Krankheit</option>
<option value="arztbesuch">Arztbesuch</option>
<option value="behoerdengang">Behördengang</option>
<option value="sonderurlaub">Sonderurlaub</option>
<option value="elternzeit_pflegezeit">Elternzeit / Pflegezeit</option>
<option value="sonstiges">Sonstiges</option>
</select>
</label>
<label>
<span>Vertretung</span>
<input type="text" name="vertretung" placeholder="Name der Vertretung">
</label>
<label>
Nachricht
<textarea name="nachricht" rows="4" placeholder="Optionale Anmerkungen"></textarea>
</label>
<button type="submit" class="btn" style="margin-top:6px">Antrag einreichen</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {
var btn = e.target.closest('.js-abw-details');
if (!btn) return;
var d = btn.dataset;
document.getElementById('abw-von').textContent = d.von;
document.getElementById('abw-bis').textContent = d.bis;
document.getElementById('abw-grund').textContent = d.grund;
document.getElementById('abw-vertretung').textContent = d.vertretung;
document.getElementById('abw-nachricht').textContent = d.nachricht;
document.getElementById('abw-eingereicht').textContent = d.eingereicht;
document.getElementById('abw-status').innerHTML =
'<span class="badge badge-' + d.statusKey + '">' + d.statusLabel + '</span>';
document.getElementById('abw-note').textContent = d.note;
document.getElementById('abw-modal').style.display = 'flex';
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') document.getElementById('abw-modal').style.display = 'none';
});
</script>
<?php layout_end(); ?>
+162
View File
@@ -0,0 +1,162 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_admin();
// Fetch all active users
$users = db()->query(
'SELECT id, name FROM users WHERE active = 1 ORDER BY name ASC'
)->fetchAll();
$now = new DateTimeImmutable('today');
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)$now->format('Y');
$month = isset($_GET['month']) ? (int)$_GET['month'] : (int)$now->format('n');
$year = max(2020, min(2099, $year));
$month = max(1, min(12, $month));
// Resolve selected user
$selected_id = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
$selected_user = null;
foreach ($users as $u) {
if ((int)$u['id'] === $selected_id) { $selected_user = $u; break; }
}
if (!$selected_user && !empty($users)) {
$selected_user = $users[0];
$selected_id = (int)$selected_user['id'];
}
$first = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
$month_start = $first->format('Y-m-d');
$month_end = $first->modify('last day of this month')->format('Y-m-d');
$days = (int)$first->format('t');
$prev = $first->modify('-1 month');
$next = $first->modify('+1 month');
$prev_url = '?user_id=' . $selected_id . '&year=' . $prev->format('Y') . '&month=' . $prev->format('n');
$next_url = '?user_id=' . $selected_id . '&year=' . $next->format('Y') . '&month=' . $next->format('n');
// Load shifts
$schichten = [];
if ($selected_id) {
$stmt = db()->prepare(
'SELECT date, schicht FROM dienstplan WHERE user_id = ? AND date >= ? AND date <= ?'
);
$stmt->execute([$selected_id, $month_start, $month_end]);
foreach ($stmt->fetchAll() as $row) {
$schichten[$row['date']] = $row['schicht'];
}
}
// Load overlays
$overlays = [];
if ($selected_id) {
$stmt = db()->prepare("
SELECT von, bis, 'urlaub' AS typ
FROM requests_urlaubsantrag
WHERE user_id = ? AND status = 'accepted' AND bis >= ? AND von <= ?
UNION ALL
SELECT von, bis, 'abwesenheit' AS typ
FROM requests_abwesenheitsantrag
WHERE user_id = ? AND status = 'accepted' AND bis >= ? AND von <= ?
");
$stmt->execute([$selected_id, $month_start, $month_end, $selected_id, $month_start, $month_end]);
foreach ($stmt->fetchAll() as $row) {
$cursor = new DateTimeImmutable($row['von']);
$end = new DateTimeImmutable($row['bis']);
while ($cursor <= $end) {
$key = $cursor->format('Y-m-d');
if ($key >= $month_start && $key <= $month_end) {
$overlays[$key] = $row['typ'];
}
$cursor = $cursor->modify('+1 day');
}
}
}
$today_str = $now->format('Y-m-d');
$schicht_labels = ['frueh' => 'Früh', 'spaet' => 'Spät', 'nacht' => 'Nacht'];
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$months_de = ['', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
layout_start('Dienstpläne', 'admin-dienstplan');
?>
<h1 class="main-title">Dienstpläne</h1>
<div class="dp-user-select-wrap">
<form method="get">
<input type="hidden" name="year" value="<?= $year ?>">
<input type="hidden" name="month" value="<?= $month ?>">
<label class="form-label" for="user_id">Mitarbeiter</label>
<select name="user_id" id="user_id" onchange="this.form.submit()">
<?php foreach ($users as $u): ?>
<option value="<?= (int)$u['id'] ?>" <?= (int)$u['id'] === $selected_id ? 'selected' : '' ?>>
<?= e($u['name']) ?>
</option>
<?php endforeach; ?>
</select>
</form>
</div>
<?php if ($selected_user): ?>
<div class="dp-nav">
<div style="display:flex;gap:10px;align-items:center">
<a href="<?= e($prev_url) ?>" class="dp-nav-btn">&larr;</a>
<h2 class="dp-nav-title"><?= e($months_de[$month] . ' ' . $year) ?></h2>
<a href="<?= e($next_url) ?>" class="dp-nav-btn">&rarr;</a>
</div>
<span style="color:var(--muted);font-size:.9rem"><?= e($selected_user['name']) ?></span>
</div>
<div class="dp-grid">
<?php foreach ($weekdays as $wd): ?>
<div class="dp-weekday"><?= e($wd) ?></div>
<?php endforeach; ?>
<?php
$lead = (int)$first->format('N') - 1;
for ($i = 0; $i < $lead; $i++): ?>
<div class="dp-cell dp-cell-empty"></div>
<?php endfor; ?>
<?php for ($d = 1; $d <= $days; $d++):
$date_key = sprintf('%04d-%02d-%02d', $year, $month, $d);
$is_today = ($date_key === $today_str);
$overlay = $overlays[$date_key] ?? null;
$schicht = $schichten[$date_key] ?? null;
$cell_cls = 'dp-cell' . ($is_today ? ' dp-cell-today' : '');
?>
<div class="<?= $cell_cls ?>">
<span class="dp-day-num"><?= $d ?></span>
<?php if ($overlay): ?>
<span class="dp-badge dp-overlay-<?= e($overlay) ?>">
<?= $overlay === 'urlaub' ? 'Urlaub' : 'Abwesenheit' ?>
</span>
<?php endif; ?>
<?php if ($schicht): ?>
<span class="dp-badge dp-badge-<?= e($schicht) ?>">
<?= e($schicht_labels[$schicht]) ?>
</span>
<?php elseif (!$overlay): ?>
<span class="dp-free"></span>
<?php endif; ?>
</div>
<?php endfor; ?>
<?php
$total = $lead + $days;
$trail = (7 - ($total % 7)) % 7;
for ($i = 0; $i < $trail; $i++): ?>
<div class="dp-cell dp-cell-empty"></div>
<?php endfor; ?>
</div>
<?php else: ?>
<p style="color:var(--muted)">Keine aktiven Mitarbeiter vorhanden.</p>
<?php endif; ?>
<?php
layout_end();
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM requests_benefitsantrag WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$requests = $stmt->fetchAll();
$benefit_labels = [
'yoga' => 'Yoga',
'autogenes_training' => 'Autogenes Training',
'massage' => 'Massage',
'tankgutschein' => 'Tankgutschein',
'online_gutscheine' => 'Online-Gutscheine',
];
$status_labels = ['pending' => 'Ausstehend', 'accepted' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Benefitsantrag', 'benefitsantrag');
?>
<style>
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:1000}
.modal-box{background:var(--glass-bg,#1e2235);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:28px 32px;max-width:480px;width:90%;position:relative}
.modal-box h3{margin:0 0 20px;font-family:'KindelSerif',Georgia,serif;font-size:1.3rem}
.modal-close{position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted,#888);line-height:1}
.modal-dl{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:.95rem}
.modal-dl dt{color:var(--muted,#888);white-space:nowrap}
.modal-dl dd{margin:0;font-weight:500}
.btn-sm{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:9px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:#fff;font-weight:600;font-size:.82rem;cursor:pointer;font:inherit;transition:background .15s}
.btn-sm:hover{background:rgba(255,255,255,.15)}
</style>
<h1 class="main-title">🎁 Benefitsantrag</h1>
<p class="main-sub">Beantrage einen Mitarbeiterbenefit deiner Wahl.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Antrag erfolgreich eingereicht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div id="ben-modal" class="modal-backdrop" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box glass">
<button class="modal-close" onclick="document.getElementById('ben-modal').style.display='none'">&times;</button>
<h3>Antrag Details</h3>
<dl class="modal-dl">
<dt>Benefit</dt> <dd id="ben-benefit"></dd>
<dt>Nachricht</dt> <dd id="ben-nachricht"></dd>
<dt>Eingereicht</dt><dd id="ben-eingereicht"></dd>
<dt>Status</dt> <dd id="ben-status"></dd>
<dt>Admin-Notiz</dt><dd id="ben-note"></dd>
</dl>
</div>
</div>
<div id="liberty-modal" class="modal-backdrop" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box glass" style="max-width:600px;padding:16px;text-align:center">
<button class="modal-close" onclick="document.getElementById('liberty-modal').style.display='none'">&times;</button>
<div class="success" style="margin:0 0 14px">Antrag erfolgreich eingereicht.</div>
<img src="../assets/Liberty.jpeg" alt="Liberty" style="width:100%;border-radius:10px;display:block">
</div>
</div>
<div class="page-split">
<div class="page-split-list">
<h2>Meine Anträge</h2>
<?php if (empty($requests)): ?>
<p style="color:var(--muted)">Noch keine Anträge gestellt.</p>
<?php else: ?>
<table class="requests-table">
<thead>
<tr>
<th>Benefit</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td style="font-weight:800"><?= e($benefit_labels[$r['benefit']] ?? $r['benefit']) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_labels[$r['status']] ?? $r['status']) ?></span></td>
<td>
<button class="btn-sm js-ben-details"
data-benefit="<?= e($benefit_labels[$r['benefit']] ?? $r['benefit']) ?>"
data-nachricht="<?= e($r['nachricht'] !== '' ? $r['nachricht'] : '') ?>"
data-eingereicht="<?= e(date('d.m.Y', strtotime($r['created_at']))) ?>"
data-status-label="<?= e($status_labels[$r['status']] ?? $r['status']) ?>"
data-status-key="<?= e($r['status']) ?>"
data-note="<?= e($r['admin_note'] !== '' ? $r['admin_note'] : '') ?>"
>Details</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Neuer Benefitsantrag</h2>
<form id="benefits-form" action="../actions/submit-benefitsantrag.php" method="post">
<?= csrf_field() ?>
<label>
<span>Benefit <span style="color:#ff8080">*</span></span>
<select name="benefit" required>
<option value=""> Bitte wählen </option>
<option value="yoga">Yoga</option>
<option value="autogenes_training">Autogenes Training</option>
<option value="massage">Massage</option>
<option value="tankgutschein">Tankgutschein</option>
<option value="online_gutscheine">Online-Gutscheine</option>
</select>
</label>
<label>
Nachricht
<textarea name="nachricht" rows="4" placeholder="Optionale Anmerkungen"></textarea>
</label>
<div id="benefits-form-error" class="error" style="display:none;margin-top:8px"></div>
<button type="submit" class="btn" style="margin-top:6px">Antrag einreichen</button>
</form>
</div>
</div>
</div>
<script>
var LIBERTY_BENEFITS = ['autogenes_training', 'massage'];
document.getElementById('benefits-form').addEventListener('submit', function(e) {
var benefit = this.querySelector('[name="benefit"]').value;
if (!LIBERTY_BENEFITS.includes(benefit)) return; // normal submit for other benefits
e.preventDefault();
var form = this;
var btn = form.querySelector('[type="submit"]');
btn.disabled = true;
var errEl = document.getElementById('benefits-form-error');
errEl.style.display = 'none';
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function(r) { return r.json(); }).then(function(data) {
btn.disabled = false;
if (data.ok) {
form.reset();
document.getElementById('liberty-modal').style.display = 'flex';
} else {
errEl.textContent = data.error || 'Ein Fehler ist aufgetreten.';
errEl.style.display = 'block';
}
}).catch(function() {
btn.disabled = false;
errEl.textContent = 'Ein Fehler ist aufgetreten. Bitte erneut versuchen.';
errEl.style.display = 'block';
});
});
document.addEventListener('click', function(e) {
var btn = e.target.closest('.js-ben-details');
if (!btn) return;
var d = btn.dataset;
document.getElementById('ben-benefit').textContent = d.benefit;
document.getElementById('ben-nachricht').textContent = d.nachricht;
document.getElementById('ben-eingereicht').textContent= d.eingereicht;
document.getElementById('ben-status').innerHTML =
'<span class="badge badge-' + d.statusKey + '">' + d.statusLabel + '</span>';
document.getElementById('ben-note').textContent = d.note;
document.getElementById('ben-modal').style.display = 'flex';
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.getElementById('ben-modal').style.display = 'none';
document.getElementById('liberty-modal').style.display = 'none';
}
});
</script>
<?php layout_end(); ?>
+189
View File
@@ -0,0 +1,189 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$name = current_name();
$user_id = current_user()['id'];
$news_items = db()->query('SELECT * FROM news ORDER BY date DESC, id DESC')->fetchAll();
$grund_labels = [
'arztbesuch' => 'Arztbesuch',
'behoerdengang' => 'Behördengang',
'sonderurlaub' => 'Sonderurlaub',
'elternzeit_pflegezeit' => 'Elternzeit / Pflegezeit',
'sonstiges' => 'Sonstiges',
];
$benefit_labels = [
'massage' => 'Massage',
'fitnessstudio' => 'Fitnessstudio',
'tankgutschein' => 'Tankgutschein',
'fortbildung' => 'Fortbildung',
];
$rows = [];
$stmt = db()->prepare('SELECT id, status, created_at, von, bis, vertretung, nachricht FROM requests_urlaubsantrag WHERE user_id=? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
foreach ($stmt->fetchAll() as $r) {
$rows[] = ['type' => 'urlaub', 'type_label' => 'Urlaubsantrag', 'icon' => '🌴'] + $r + ['detail' => $r['von'] . ' ' . $r['bis']];
}
$stmt = db()->prepare('SELECT id, status, created_at, von, bis, grund, vertretung, nachricht FROM requests_abwesenheitsantrag WHERE user_id=? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
foreach ($stmt->fetchAll() as $r) {
$rows[] = ['type' => 'abwesenheit', 'type_label' => 'Abwesenheitsantrag', 'icon' => '📅'] + $r + ['detail' => ($grund_labels[$r['grund']] ?? $r['grund']) . ': ' . $r['von'] . ' ' . $r['bis']];
}
$stmt = db()->prepare('SELECT id, status, created_at, benefit, nachricht FROM requests_benefitsantrag WHERE user_id=? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
foreach ($stmt->fetchAll() as $r) {
$rows[] = ['type' => 'benefits', 'type_label' => 'Benefitsantrag', 'icon' => '🎁'] + $r + ['detail' => $benefit_labels[$r['benefit']] ?? $r['benefit']];
}
$stmt = db()->prepare('SELECT id, status, created_at, name, email, qualifikation, nachricht FROM requests_werben WHERE user_id=? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
foreach ($stmt->fetchAll() as $r) {
$rows[] = ['type' => 'werben', 'type_label' => 'Mitarbeiter werben', 'icon' => '🤝'] + $r + ['detail' => $r['name'] . ($r['qualifikation'] ? ' (' . $r['qualifikation'] . ')' : '')];
}
usort($rows, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$pending_count = count(array_filter($rows, fn($r) => $r['status'] === 'pending'));
$status_map = ['pending' => 'Ausstehend', 'accepted' => 'Angenommen', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Felix Dashboard', 'dashboard');
?>
<h1 class="main-title">Willkommen, <span><?= e($name) ?></span>!</h1>
<p class="main-sub">Hier siehst du alle deine Anträge auf einen Blick.</p>
<?php if (!empty($news_items)): ?>
<div style="margin:28px 0 8px">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.5rem;margin:0 0 14px">📢 News & Ankündigungen</h2>
<?php foreach ($news_items as $n): ?>
<div class="glass" style="padding:20px 24px;border-radius:18px;margin-bottom:12px">
<div style="color:var(--cyan2);font-size:.82rem;font-weight:900;margin-bottom:5px">
<?= e(date('d.m.Y', strtotime($n['date']))) ?>
</div>
<strong style="font-size:1.05rem;display:block;margin-bottom:6px"><?= e($n['title']) ?></strong>
<p style="margin:0;color:var(--muted);line-height:1.6;font-size:.95rem"><?= nl2br(e($n['text'])) ?></p>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($pending_count > 0): ?>
<p style="margin:18px 0 0;color:var(--muted)">
<?= $pending_count === 1 ? '1 Antrag ist noch' : $pending_count . ' Anträge sind noch' ?> <strong style="color:#ffe566">ausstehend</strong>.
</p>
<?php endif; ?>
<?php if (empty($rows)): ?>
<p style="margin-top:32px;color:var(--muted)">Du hast noch keine Anträge gestellt.</p>
<?php else: ?>
<div class="type-tabs" id="type-tabs">
<button class="type-tab active" data-type="all">Alle (<?= count($rows) ?>)</button>
<?php
$types = [];
foreach ($rows as $r) {
$types[$r['type']] = ['label' => $r['type_label'], 'icon' => $r['icon'], 'count' => ($types[$r['type']]['count'] ?? 0) + 1];
}
foreach ($types as $type => $info): ?>
<button class="type-tab" data-type="<?= e($type) ?>"><?= $info['icon'] ?> <?= e($info['label']) ?> (<?= $info['count'] ?>)</button>
<?php endforeach; ?>
</div>
<table class="requests-table" style="margin-top:16px">
<thead>
<tr>
<th>Art</th>
<th>Details</th>
<th>Eingereicht</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr data-type="<?= e($r['type']) ?>">
<td><?= $r['icon'] ?> <?= e($r['type_label']) ?></td>
<td style="color:var(--muted);font-size:.93rem"><?= e($r['detail']) ?></td>
<td style="color:var(--muted);font-size:.88rem;white-space:nowrap"><?= e(date('d.m.Y', strtotime($r['created_at']))) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_map[$r['status']] ?? $r['status']) ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<script>
document.getElementById('type-tabs').addEventListener('click', function(e) {
const btn = e.target.closest('.type-tab');
if (!btn) return;
this.querySelectorAll('.type-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const type = btn.dataset.type;
document.querySelectorAll('.requests-table tbody tr').forEach(row => {
row.style.display = (type === 'all' || row.dataset.type === type) ? '' : 'none';
});
});
</script>
<?php endif; ?>
<?php layout_end(); ?>
<div id="pwa-install-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#1a3a5c;color:#fff;padding:.85rem 1rem;z-index:8000;align-items:center;gap:.75rem;box-shadow:0 -2px 12px rgba(0,0,0,.25);">
<span style="flex:1;font-size:.95rem;">📲 <strong>OMSORG App</strong> zum Home-Bildschirm hinzufügen</span>
<button id="pwa-install-confirm" type="button" style="background:#fff;color:#1a3a5c;border:none;border-radius:8px;padding:.45rem 1rem;font-weight:700;cursor:pointer;white-space:nowrap;">Installieren</button>
<button id="pwa-install-dismiss" type="button" style="background:transparent;color:#fff;border:none;font-size:1.2rem;cursor:pointer;padding:0 .25rem;line-height:1;">✕</button>
</div>
<div id="pwa-install-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:flex-end;justify-content:center;padding:1rem;" role="dialog" aria-modal="true">
<div style="background:#fff;border-radius:16px;padding:1.5rem;width:100%;max-width:440px;color:#1a3a5c;">
<p style="margin:0 0 1.25rem;font-weight:700;font-size:1.1rem;text-align:center;">📲 App installieren</p>
<div style="background:#f0f4ff;border-radius:12px;padding:1rem 1.1rem;margin-bottom:.75rem;">
<p style="margin:0 0 .4rem;font-weight:700;font-size:.95rem;">🤖 Android (Chrome)</p>
<p style="margin:0;font-size:.9rem;line-height:1.6;color:#444;">Tippe oben rechts auf das <strong>Menü ⋮</strong> und dann auf<br><strong>„App installieren"</strong> oder <strong>„Zum Startbildschirm hinzufügen"</strong>.</p>
</div>
<div style="background:#f0f4ff;border-radius:12px;padding:1rem 1.1rem;margin-bottom:1.25rem;">
<p style="margin:0 0 .4rem;font-weight:700;font-size:.95rem;">🍎 iPhone / iPad (Safari)</p>
<p style="margin:0;font-size:.9rem;line-height:1.6;color:#444;">Tippe unten auf das <strong>Teilen-Symbol ⬆</strong> und dann auf<br><strong>„Zum Home-Bildschirm"</strong>.</p>
</div>
<button id="pwa-modal-close" style="display:block;width:100%;background:#1a3a5c;color:#fff;border:none;border-radius:8px;padding:.65rem;cursor:pointer;font-size:.95rem;font-weight:600;">Schließen</button>
</div>
</div>
<script>
(function(){
if(window.matchMedia('(display-mode: standalone)').matches)return;
if(document.cookie.split(';').some(c=>c.trim()==='pwa_dismissed=1'))return;
const banner=document.getElementById('pwa-install-banner');
const modal=document.getElementById('pwa-install-modal');
let deferred=null;
window.addEventListener('beforeinstallprompt',function(e){
e.preventDefault();
deferred=e;
});
banner.style.display='flex';
document.getElementById('pwa-install-confirm').addEventListener('click',function(){
if(deferred){
deferred.prompt();
banner.style.display='none';
} else {
banner.style.display='none';
modal.style.display='flex';
}
});
function dismiss(){
banner.style.display='none';
document.cookie='pwa_dismissed=1;path=/;max-age='+60*60*24*365;
}
document.getElementById('pwa-install-dismiss').addEventListener('click',dismiss);
document.getElementById('pwa-modal-close').addEventListener('click',function(){
modal.style.display='none';
dismiss();
});
})();
</script>
+184
View File
@@ -0,0 +1,184 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user = current_user();
$user_id = $user['id'];
$now = new DateTimeImmutable('today');
$year = isset($_GET['year']) ? (int)$_GET['year'] : (int)$now->format('Y');
$month = isset($_GET['month']) ? (int)$_GET['month'] : (int)$now->format('n');
$year = max(2020, min(2099, $year));
$month = max(1, min(12, $month));
$first = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
$month_start = $first->format('Y-m-d');
$month_end = $first->modify('last day of this month')->format('Y-m-d');
$days = (int)$first->format('t');
$prev = $first->modify('-1 month');
$next = $first->modify('+1 month');
$prev_url = '?year=' . $prev->format('Y') . '&month=' . $prev->format('n');
$next_url = '?year=' . $next->format('Y') . '&month=' . $next->format('n');
$stmt = db()->prepare(
'SELECT date, schicht FROM dienstplan WHERE user_id = ? AND date >= ? AND date <= ?'
);
$stmt->execute([$user_id, $month_start, $month_end]);
$schichten = [];
foreach ($stmt->fetchAll() as $row) {
$schichten[$row['date']] = $row['schicht'];
}
$stmt = db()->prepare("
SELECT von, bis, 'urlaub' AS typ
FROM requests_urlaubsantrag
WHERE user_id = ? AND status = 'accepted' AND bis >= ? AND von <= ?
UNION ALL
SELECT von, bis, 'abwesenheit' AS typ
FROM requests_abwesenheitsantrag
WHERE user_id = ? AND status = 'accepted' AND bis >= ? AND von <= ?
");
$stmt->execute([$user_id, $month_start, $month_end, $user_id, $month_start, $month_end]);
$overlays = [];
foreach ($stmt->fetchAll() as $row) {
$cursor = new DateTimeImmutable($row['von']);
$end = new DateTimeImmutable($row['bis']);
while ($cursor <= $end) {
$key = $cursor->format('Y-m-d');
if ($key >= $month_start && $key <= $month_end) {
$overlays[$key] = $row['typ'];
}
$cursor = $cursor->modify('+1 day');
}
}
$today_str = $now->format('Y-m-d');
$ea_stmt = db()->prepare('SELECT ort FROM einsatzanweisung WHERE user_id = ?');
$ea_stmt->execute([$user_id]);
$ea_row = $ea_stmt->fetch();
$ea_ort = ($ea_row && $ea_row['ort'] !== '') ? $ea_row['ort'] : '';
$schicht_labels = ['frueh' => 'Früh', 'spaet' => 'Spät', 'nacht' => 'Nacht'];
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$months_de = ['', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
layout_start('Dienstplan', 'dienstplan');
?>
<h1 class="main-title">Dienstplan</h1>
<?php if ($ea_ort !== ''): ?>
<p class="dp-ort">&#128205; Einsatzort: <strong><?= e($ea_ort) ?></strong></p>
<?php endif; ?>
<div class="dp-nav">
<div style="display:flex;gap:10px;align-items:center">
<a href="<?= e($prev_url) ?>" class="dp-nav-btn">&larr;</a>
<h2 class="dp-nav-title"><?= e($months_de[$month] . ' ' . $year) ?></h2>
<a href="<?= e($next_url) ?>" class="dp-nav-btn">&rarr;</a>
</div>
<span id="dp-status" style="font-size:.85rem;color:var(--muted);transition:opacity .4s"></span>
</div>
<div class="dp-grid-wrap">
<div class="dp-grid">
<?php foreach ($weekdays as $wd): ?>
<div class="dp-weekday"><?= e($wd) ?></div>
<?php endforeach; ?>
<?php
$lead = (int)$first->format('N') - 1;
for ($i = 0; $i < $lead; $i++): ?>
<div class="dp-cell dp-cell-empty"></div>
<?php endfor; ?>
<?php for ($d = 1; $d <= $days; $d++):
$date_key = sprintf('%04d-%02d-%02d', $year, $month, $d);
$is_today = ($date_key === $today_str);
$overlay = $overlays[$date_key] ?? null;
$schicht = $schichten[$date_key] ?? 'leer';
$cell_cls = 'dp-cell';
if (!$overlay && $schicht !== 'leer') $cell_cls .= ' dp-cell-' . $schicht;
if ($is_today) $cell_cls .= ' dp-cell-today';
?>
<div class="<?= $cell_cls ?>">
<span class="dp-day-num"><?= $d ?></span>
<?php if ($overlay): ?>
<span class="dp-badge dp-overlay-<?= e($overlay) ?>">
<?= $overlay === 'urlaub' ? 'Urlaub' : 'Abwesenheit' ?>
</span>
<?php if ($schicht !== 'leer'): ?>
<span class="dp-badge dp-badge-<?= e($schicht) ?>" style="opacity:.45">
<?= e($schicht_labels[$schicht]) ?>
</span>
<?php endif; ?>
<?php else: ?>
<select data-date="<?= e($date_key) ?>" class="dp-select">
<option value="leer" <?= $schicht === 'leer' ? 'selected' : '' ?>>Frei</option>
<option value="frueh" <?= $schicht === 'frueh' ? 'selected' : '' ?>>Früh</option>
<option value="spaet" <?= $schicht === 'spaet' ? 'selected' : '' ?>>Spät</option>
<option value="nacht" <?= $schicht === 'nacht' ? 'selected' : '' ?>>Nacht</option>
</select>
<?php endif; ?>
</div>
<?php endfor; ?>
<?php
$total = $lead + $days;
$trail = (7 - ($total % 7)) % 7;
for ($i = 0; $i < $trail; $i++): ?>
<div class="dp-cell dp-cell-empty"></div>
<?php endfor; ?>
</div>
</div>
<script>
(function(){
var YEAR = <?= (int)$year ?>;
var MONTH = <?= (int)$month ?>;
function colorCell(sel){
var cell = sel.closest('.dp-cell');
cell.classList.remove('dp-cell-frueh','dp-cell-spaet','dp-cell-nacht');
if(sel.value !== 'leer') cell.classList.add('dp-cell-' + sel.value);
}
var statusEl = document.getElementById('dp-status');
var statusTimer;
function showStatus(ok){
clearTimeout(statusTimer);
statusEl.style.opacity = '1';
statusEl.style.color = ok ? '#6eff9a' : '#ff9a9a';
statusEl.textContent = ok ? 'Gespeichert' : 'Fehler beim Speichern';
statusTimer = setTimeout(function(){ statusEl.style.opacity = '0'; }, 1800);
}
document.querySelectorAll('.dp-select').forEach(function(sel){
colorCell(sel);
sel.addEventListener('change', function(){
var date = this.dataset.date;
var schicht = this.value;
colorCell(this);
var body = new URLSearchParams();
body.append('year', YEAR);
body.append('month', MONTH);
body.append('schicht[' + date + ']', schicht);
body.append('_ajax', '1');
body.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content);
fetch('../actions/save-dienstplan.php', {method:'POST', body:body})
.then(function(r){ return r.json(); })
.then(function(d){ showStatus(d.ok); })
.catch(function(){ showStatus(false); });
});
});
})();
</script>
<?php
layout_end();
+34
View File
@@ -0,0 +1,34 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_login();
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) { http_response_code(400); echo 'Ungültige Anfrage.'; exit; }
$stmt = db()->prepare('SELECT * FROM dokumente WHERE id = ?');
$stmt->execute([$id]);
$doc = $stmt->fetch();
if (!$doc) { http_response_code(404); echo 'Dokument nicht gefunden.'; exit; }
$user = current_user();
if ((int) $doc['user_id'] !== (int) $user['id'] && !is_admin()) {
http_response_code(403);
echo 'Keine Berechtigung.';
exit;
}
$path = dirname(__DIR__) . '/uploads/' . $doc['filename'];
if (!is_file($path)) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$display = $doc['original_name'];
$safe_display = addslashes($display);
$safe_encoded = rawurlencode($display);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $safe_display . '"; filename*=UTF-8\'\'' . $safe_encoded);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
readfile($path);
exit;
+110
View File
@@ -0,0 +1,110 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$admin = is_admin();
if ($admin) {
$docs = db()->query(
'SELECT d.*, u.name AS user_name
FROM dokumente d
JOIN users u ON u.id = d.user_id
ORDER BY d.created_at DESC'
)->fetchAll();
} else {
$stmt = db()->prepare('SELECT * FROM dokumente WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$docs = $stmt->fetchAll();
}
layout_start('OMSORG Dokumentenarchiv', 'dokumentenarchiv');
?>
<h1 class="main-title">🗂 Dokumenten&shy;archiv</h1>
<p class="main-sub">Eigene Nachweise und Dokumente hochladen und verwalten.</p>
<?php if (isset($_GET['ok'])): ?>
<div class="success" style="margin-top:16px">Dokument erfolgreich hochgeladen.</div>
<?php elseif (isset($_GET['deleted'])): ?>
<div class="success" style="margin-top:16px">Dokument wurde gelöscht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div class="page-split" style="margin-top:24px">
<div class="page-split-list">
<h2><?= $admin ? 'Alle Dokumente' : 'Meine Dokumente' ?></h2>
<?php if (empty($docs)): ?>
<p style="color:var(--muted)">Noch keine Dokumente vorhanden.</p>
<?php else: ?>
<table class="requests-table">
<thead>
<tr>
<?php if ($admin): ?><th>Mitarbeiter</th><?php endif; ?>
<th>Kategorie</th>
<th>Beschreibung</th>
<th>Datei</th>
<th>Hochgeladen</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($docs as $d): ?>
<tr>
<?php if ($admin): ?>
<td style="font-weight:800"><?= e($d['user_name']) ?></td>
<?php endif; ?>
<td><?= e($d['kategorie']) ?></td>
<td style="color:var(--muted);font-size:.9rem"><?= $d['beschreibung'] !== '' ? e($d['beschreibung']) : '' ?></td>
<td>
<a href="dokument-serve.php?id=<?= (int)$d['id'] ?>" class="btn download-btn" style="padding:6px 14px;font-size:.85rem">&#8595; <?= e($d['original_name']) ?></a>
</td>
<td style="color:var(--muted);font-size:.9rem"><?= e(date('d.m.Y', strtotime($d['created_at']))) ?></td>
<td>
<form method="post" action="../actions/delete-dokument.php" onsubmit="return confirm('Dokument wirklich löschen?')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$d['id'] ?>">
<button type="submit" class="btn" style="background:rgba(220,50,50,.25);padding:6px 14px;font-size:.85rem">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Dokument hochladen</h2>
<form action="../actions/upload-dokument.php" method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<label>
<span>Kategorie <span style="color:#ff8080">*</span></span>
<select name="kategorie" required>
<option value=""> Bitte wählen </option>
<option value="Fortbildungsnachweis">Fortbildungsnachweis</option>
<option value="Zeugnis">Zeugnis</option>
<option value="Bescheinigung">Bescheinigung</option>
<option value="Sonstiges">Sonstiges</option>
</select>
</label>
<label>
Beschreibung
<input type="text" name="beschreibung" placeholder="z. B. Erste-Hilfe-Kurs 2025">
</label>
<label>
<span>Datei <span style="color:#ff8080">*</span></span>
<input type="file" name="datei" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png" required>
<span class="hint">PDF, Word, JPG, PNG &mdash; max. 12 MB</span>
</label>
<button type="submit" class="btn" style="margin-top:6px">Hochladen</button>
</form>
</div>
</div>
</div>
<?php layout_end(); ?>
+25
View File
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_login();
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) { http_response_code(400); echo 'Ungültige Anfrage.'; exit; }
$stmt = db()->prepare('SELECT filename, original_name FROM downloads WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$path = dirname(__DIR__) . '/downloads/' . $row['filename'];
if (!is_file($path)) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$safe_display = addslashes($row['original_name']);
$safe_encoded = rawurlencode($row['original_name']);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $safe_display . '"; filename*=UTF-8\'\'' . $safe_encoded);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
readfile($path);
exit;
+28
View File
@@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$downloads = db()->query('SELECT * FROM downloads ORDER BY sort_order ASC, id ASC')->fetchAll();
layout_start('OMSORG Downloads', 'downloads');
?>
<h1 class="main-title">Down<span>loads</span></h1>
<p class="main-sub">Dokumente und Formulare zum Herunterladen.</p>
<?php if (empty($downloads)): ?>
<p style="color:var(--muted);margin-top:40px">Noch keine Downloads verfügbar.</p>
<?php else: ?>
<div class="downloads-grid">
<?php foreach ($downloads as $dl): ?>
<div class="glass download-card">
<h3 class="download-card-title"><?= e($dl['title']) ?></h3>
<?php if ($dl['description'] !== ''): ?>
<p class="download-card-desc"><?= e($dl['description']) ?></p>
<?php endif; ?>
<a href="download-serve.php?id=<?= (int)$dl['id'] ?>" class="btn download-btn">&#8595; Herunterladen</a>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php layout_end(); ?>
@@ -0,0 +1,40 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_login();
$current = current_user();
if (is_admin() && isset($_GET['user_id'])) {
$target_id = (int)$_GET['user_id'];
} else {
$target_id = (int)$current['id'];
}
$stmt = db()->prepare('SELECT * FROM einsatzanweisung WHERE user_id = ?');
$stmt->execute([$target_id]);
$ea = $stmt->fetch();
if (!$ea || $ea['filename'] === '') {
http_response_code(404);
echo 'Keine Datei gefunden.';
exit;
}
$path = dirname(__DIR__) . '/uploads/' . basename($ea['filename']);
if (!is_file($path)) {
http_response_code(404);
echo 'Datei nicht gefunden.';
exit;
}
$display = $ea['original_name'];
$safe_display = addslashes($display);
$safe_encoded = rawurlencode($display);
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $safe_display . '"; filename*=UTF-8\'\'' . $safe_encoded);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
readfile($path);
exit;
@@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM einsatzanweisung WHERE user_id = ?');
$stmt->execute([$user_id]);
$ea = $stmt->fetch();
layout_start('OMSORG Einsatzanweisung', 'einsatzanweisung');
?>
<h1 class="main-title">Einsatz<span>anweisung</span></h1>
<p class="main-sub">Deine persönliche Einsatzanweisung als PDF-Dokument.</p>
<?php if (!$ea || ($ea['filename'] === '' && $ea['ort'] === '')): ?>
<p style="color:var(--muted);margin-top:40px">Noch keine Einsatzanweisung hinterlegt.</p>
<?php else: ?>
<div class="glass" style="max-width:520px;padding:28px;border-radius:20px;margin-top:24px">
<?php if ($ea['ort'] !== ''): ?>
<div style="margin-bottom:24px">
<div style="color:var(--cyan2);font-size:.78rem;font-weight:900;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px">Einsatzort</div>
<div style="font-size:1.15rem;font-weight:800"><?= e($ea['ort']) ?></div>
</div>
<?php endif; ?>
<?php if ($ea['filename'] !== ''): ?>
<div>
<div style="color:var(--cyan2);font-size:.78rem;font-weight:900;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px">Dokument</div>
<a href="einsatzanweisung-serve.php" class="btn" style="display:inline-flex;align-items:center;gap:8px">
&#8595; <?= e($ea['original_name']) ?>
</a>
<?php if ($ea['uploaded_at'] !== ''): ?>
<div style="color:var(--muted);font-size:.82rem;margin-top:8px">
Hochgeladen: <?= e(date('d.m.Y', strtotime($ea['uploaded_at']))) ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php layout_end(); ?>
+181
View File
@@ -0,0 +1,181 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM einsatzbewertungen WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$bewertungen = $stmt->fetchAll();
$extra_head = <<<'CSS'
<style>
.star-rating{display:flex;flex-direction:row-reverse;gap:4px;justify-content:flex-end}
.star-rating input{display:none}
.star-rating label{
font-size:2rem;cursor:pointer;color:rgba(170,240,250,.25);
transition:color .1s;line-height:1;
}
.star-rating input:checked ~ label,
.star-rating label:hover,
.star-rating label:hover ~ label{color:#f5c518}
.star-display{color:#f5c518;letter-spacing:2px;font-size:1.1rem}
.star-display .empty{color:rgba(170,240,250,.2)}
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:1000}
.modal-box{background:var(--glass-bg,#1e2235);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:28px 32px;max-width:480px;width:90%;position:relative}
.modal-box h3{margin:0 0 20px;font-family:'KindelSerif',Georgia,serif;font-size:1.3rem}
.modal-close{position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted,#888);line-height:1}
.modal-dl{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:.95rem}
.modal-dl dt{color:var(--muted,#888);white-space:nowrap}
.modal-dl dd{margin:0;font-weight:500}
.btn-sm{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:9px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:#fff;font-weight:600;font-size:.82rem;cursor:pointer;font:inherit;transition:background .15s}
.btn-sm:hover{background:rgba(255,255,255,.15)}
</style>
CSS;
layout_start('OMSORG Einsatzbewertung', 'einsatzbewertung', $extra_head);
?>
<h1 class="main-title">⭐ Einsatzbewertung</h1>
<p class="main-sub">Bewerte deinen Einsatzort. Dein Feedback hilft uns, die Einsatzplanung zu verbessern.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Bewertung erfolgreich abgegeben.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div id="bew-modal" class="modal-backdrop" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box glass">
<button class="modal-close" onclick="document.getElementById('bew-modal').style.display='none'">&times;</button>
<h3>Bewertung Details</h3>
<dl class="modal-dl">
<dt>Einsatzort</dt> <dd id="bew-ort"></dd>
<dt>Von</dt> <dd id="bew-von"></dd>
<dt>Bis</dt> <dd id="bew-bis"></dd>
<dt>Bewertung</dt> <dd id="bew-sterne"></dd>
<dt>Wieder?</dt> <dd id="bew-wieder"></dd>
<dt>Feedback</dt> <dd id="bew-feedback"></dd>
<dt>Datum</dt> <dd id="bew-datum"></dd>
</dl>
</div>
</div>
<div class="page-split">
<div class="page-split-list">
<h2>Meine Bewertungen</h2>
<?php if (empty($bewertungen)): ?>
<p style="color:var(--muted)">Noch keine Bewertungen abgegeben.</p>
<?php else: ?>
<table class="requests-table">
<thead>
<tr>
<th>Einsatzort</th>
<th>Bewertung</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($bewertungen as $b): ?>
<tr>
<td style="font-weight:800"><?= e($b['einsatzort']) ?></td>
<td>
<span class="star-display">
<?php for ($i = 1; $i <= 5; $i++): ?>
<span<?= $i > $b['bewertung'] ? ' class="empty"' : '' ?>>★</span>
<?php endfor; ?>
</span>
</td>
<td>
<button class="btn-sm js-bew-details"
data-ort="<?= e($b['einsatzort']) ?>"
data-von="<?= e(date('d.m.Y', strtotime($b['von']))) ?>"
data-bis="<?= e(date('d.m.Y', strtotime($b['bis']))) ?>"
data-bewertung="<?= (int)$b['bewertung'] ?>"
data-wieder="<?= (int)$b['wieder'] ?>"
data-feedback="<?= e($b['feedback'] !== '' ? $b['feedback'] : '') ?>"
data-datum="<?= e(date('d.m.Y', strtotime($b['created_at']))) ?>"
>Details</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Neue Bewertung</h2>
<form action="../actions/submit-einsatzbewertung.php" method="post">
<?= csrf_field() ?>
<label>
<span>Einsatzort / Einrichtung <span style="color:#ff8080">*</span></span>
<input type="text" name="einsatzort" required placeholder="z. B. Pflegeheim Sonnenhof">
</label>
<label>
<span>Von <span style="color:#ff8080">*</span></span>
<input type="date" name="von" required>
</label>
<label>
<span>Bis <span style="color:#ff8080">*</span></span>
<input type="date" name="bis" required>
</label>
<div style="margin-bottom:14px">
<span style="display:block;margin-bottom:8px;font-weight:700">
Bewertung <span style="color:#ff8080">*</span>
</span>
<div class="star-rating">
<input type="radio" id="star5" name="bewertung" value="5" required>
<label for="star5" title="5 Sterne">★</label>
<input type="radio" id="star4" name="bewertung" value="4">
<label for="star4" title="4 Sterne">★</label>
<input type="radio" id="star3" name="bewertung" value="3">
<label for="star3" title="3 Sterne">★</label>
<input type="radio" id="star2" name="bewertung" value="2">
<label for="star2" title="2 Sterne">★</label>
<input type="radio" id="star1" name="bewertung" value="1">
<label for="star1" title="1 Stern">★</label>
</div>
</div>
<div style="margin-bottom:14px">
<label style="display:flex;align-items:center;gap:10px;flex-direction:row;font-weight:700;cursor:pointer">
<input type="checkbox" name="wieder" value="1" style="width:18px;height:18px;cursor:pointer">
Würdest du wieder dort arbeiten?
</label>
</div>
<label>
Feedback
<textarea name="feedback" rows="4" placeholder="Optionale Anmerkungen zu diesem Einsatz"></textarea>
</label>
<button type="submit" class="btn" style="margin-top:6px">Bewertung abgeben</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {
var btn = e.target.closest('.js-bew-details');
if (!btn) return;
var d = btn.dataset;
document.getElementById('bew-ort').textContent = d.ort;
document.getElementById('bew-von').textContent = d.von;
document.getElementById('bew-bis').textContent = d.bis;
var n = parseInt(d.bewertung, 10);
var stars = '';
for (var i = 1; i <= 5; i++) stars += '<span' + (i > n ? ' class="empty"' : '') + '>★</span>';
document.getElementById('bew-sterne').innerHTML = '<span class="star-display">' + stars + '</span>';
document.getElementById('bew-wieder').innerHTML =
parseInt(d.wieder) ? '<span style="color:#6eff9a;font-weight:800">Ja</span>' : '<span style="color:var(--muted)">Nein</span>';
document.getElementById('bew-feedback').textContent = d.feedback;
document.getElementById('bew-datum').textContent = d.datum;
document.getElementById('bew-modal').style.display = 'flex';
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') document.getElementById('bew-modal').style.display = 'none';
});
</script>
<?php layout_end(); ?>
@@ -0,0 +1,25 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_login();
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) { http_response_code(400); echo 'Ungültige Anfrage.'; exit; }
$stmt = db()->prepare('SELECT filename, original_name FROM fortbildung_materials WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$path = dirname(__DIR__) . '/fortbildung-materials/' . $row['filename'];
if (!is_file($path)) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$safe_display = addslashes($row['original_name']);
$safe_encoded = rawurlencode($row['original_name']);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $safe_display . '"; filename*=UTF-8\'\'' . $safe_encoded);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
readfile($path);
exit;
@@ -0,0 +1,116 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$materials = db()->query('SELECT * FROM fortbildung_materials ORDER BY sort_order ASC, id ASC')->fetchAll();
$stmt = db()->prepare('SELECT * FROM requests_fortbildungsantrag WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$requests = $stmt->fetchAll();
$anliegen_labels = [
'wbl_pdl' => 'Anfrage WBL/PDL Weiterbildung',
'fortbildung' => 'Fortbildung anfragen',
'pflichtschulung' => 'Pflichtschulung',
];
$status_labels = ['pending' => 'Ausstehend', 'accepted' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Fortbildungsantrag', 'fortbildungsantrag');
?>
<h1 class="main-title">🎓 Fortbildungs&shy;antrag</h1>
<p class="main-sub">Unterlagen herunterladen und Fortbildungsanträge einreichen.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Antrag erfolgreich eingereicht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div class="page-split" style="margin-top:24px">
<div class="page-split-list">
<h2>Unterlagen</h2>
<?php if (empty($materials)): ?>
<p style="color:var(--muted)">Noch keine Unterlagen verfügbar.</p>
<?php else: ?>
<div class="downloads-grid">
<?php foreach ($materials as $m): ?>
<div class="glass download-card">
<h3 class="download-card-title"><?= e($m['title']) ?></h3>
<?php if ($m['description'] !== ''): ?>
<p class="download-card-desc"><?= e($m['description']) ?></p>
<?php endif; ?>
<a href="fortbildung-serve.php?id=<?= (int)$m['id'] ?>" class="btn download-btn">&#8595; Herunterladen</a>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Neuer Antrag</h2>
<form action="../actions/submit-fortbildungsantrag.php" method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<label>
<span>Anliegen <span style="color:#ff8080">*</span></span>
<select name="anliegen" required>
<option value=""> Bitte wählen </option>
<option value="wbl_pdl">Anfrage WBL/PDL Weiterbildung</option>
<option value="fortbildung">Fortbildung anfragen</option>
<option value="pflichtschulung">Pflichtschulung</option>
</select>
</label>
<label>
<span>Thema <span style="color:#ff8080">*</span></span>
<input type="text" name="thema" required placeholder="z. B. Erste Hilfe, Demenzpflege …">
</label>
<label>
Nachricht
<textarea name="nachricht" rows="4" placeholder="Weitere Informationen oder Wünsche"></textarea>
</label>
<label>
Anhang
<input type="file" name="datei" accept=".pdf,.doc,.docx,.jpg,.jpeg,.png">
<span class="hint">PDF, Word, JPG, PNG &mdash; max. 12 MB (optional)</span>
</label>
<button type="submit" class="btn" style="margin-top:6px">Antrag einreichen</button>
</form>
</div>
</div>
</div>
<?php if (!empty($requests)): ?>
<div style="margin-top:40px">
<h2>Meine Anträge</h2>
<table class="requests-table">
<thead>
<tr>
<th>Anliegen</th>
<th>Thema</th>
<th>Eingereicht</th>
<th>Status</th>
<th>Notiz</th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?= e($anliegen_labels[$r['anliegen']] ?? $r['anliegen']) ?></td>
<td style="font-weight:800"><?= e($r['thema']) ?></td>
<td style="color:var(--muted);font-size:.9rem"><?= e(date('d.m.Y', strtotime($r['created_at']))) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_labels[$r['status']] ?? $r['status']) ?></span></td>
<td style="color:var(--muted);font-size:.9rem"><?= $r['admin_note'] !== '' ? e($r['admin_note']) : '' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php layout_end(); ?>
+134
View File
@@ -0,0 +1,134 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$username = current_username();
$user = current_user();
$saved = isset($_GET['saved']);
$pwchanged = isset($_GET['pwchanged']);
$avatar_saved = isset($_GET['avatar_saved']);
$error = isset($_GET['error']) ? urldecode($_GET['error']) : '';
layout_start('OMSORG Einstellungen', 'settings');
?>
<h1 class="main-title">Einstellungen</h1>
<p class="main-sub">Deine persönlichen Daten und Passwort.</p>
<?php if ($saved): ?>
<div class="success" style="margin-top:20px">Profil erfolgreich gespeichert.</div>
<?php elseif ($pwchanged): ?>
<div class="success" style="margin-top:20px">Passwort erfolgreich geändert.</div>
<?php elseif ($avatar_saved): ?>
<div class="success" style="margin-top:20px">Profilbild erfolgreich gespeichert.</div>
<?php elseif ($error): ?>
<div class="error" style="margin-top:20px"><?= e($error) ?></div>
<?php endif; ?>
<!-- Profilbild -->
<div class="settings-section">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.5rem;margin:0 0 18px">Profilbild</h2>
<div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:20px">
<?php if (!empty($user['avatar'])): ?>
<img id="avatar-preview" src="../assets/avatars/<?= e($user['avatar']) ?>"
alt="Profilbild" class="settings-avatar-preview">
<?php else: ?>
<div id="avatar-preview-placeholder" class="settings-avatar-placeholder">
<?= e(mb_substr($user['name'], 0, 1)) ?>
</div>
<img id="avatar-preview" src="" alt="Vorschau" class="settings-avatar-preview"
style="display:none">
<?php endif; ?>
<div>
<form action="../actions/upload-avatar.php" method="post" enctype="multipart/form-data"
style="display:flex;flex-direction:column;gap:10px;align-items:flex-start">
<?= csrf_field() ?>
<label class="btn" style="cursor:pointer;margin:0">
Bild auswählen
<input type="file" name="avatar" accept="image/*" id="avatar-input"
style="display:none" onchange="previewAvatar(this)">
</label>
<button type="submit" class="btn" id="avatar-upload-btn" style="display:none">
Hochladen
</button>
</form>
<?php if (!empty($user['avatar'])): ?>
<form action="../actions/upload-avatar.php" method="post" style="margin-top:8px">
<?= csrf_field() ?>
<input type="hidden" name="remove_avatar" value="1">
<button type="submit" class="btn btn-ghost" style="font-size:.85rem;padding:6px 14px">
Bild entfernen
</button>
</form>
<?php endif; ?>
</div>
</div>
<p style="color:var(--muted);font-size:.82rem;margin:0">JPG, PNG, GIF oder WebP · max. 3 MB</p>
</div>
<!-- Profildaten -->
<div class="settings-section">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.5rem;margin:0 0 18px">Profildaten</h2>
<form action="../actions/save-profile.php" method="post" style="max-width:480px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="profile">
<label>
Name
<input type="text" value="<?= e($user['name']) ?>" disabled>
</label>
<label>
Benutzername
<input type="text" value="<?= e($username) ?>" disabled>
</label>
<label>
E-Mail
<input type="email" name="email" autocomplete="email"
value="<?= e($user['email'] ?? '') ?>">
</label>
<label>
Telefon
<input type="tel" name="telefon" autocomplete="tel"
value="<?= e($user['telefon'] ?? '') ?>">
</label>
<button type="submit" class="btn">Speichern</button>
</form>
</div>
<!-- Passwort ändern -->
<div class="settings-section">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.5rem;margin:0 0 18px">Passwort ändern</h2>
<form action="../actions/save-profile.php" method="post" style="max-width:480px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="password">
<label>
Aktuelles Passwort
<input type="password" name="old_password" autocomplete="current-password" required>
</label>
<label>
Neues Passwort
<input type="password" name="new_password" autocomplete="new-password" required minlength="8">
</label>
<label>
Neues Passwort wiederholen
<input type="password" name="new_password_repeat" autocomplete="new-password" required minlength="8">
</label>
<button type="submit" class="btn">Passwort ändern</button>
</form>
</div>
<script>
function previewAvatar(input) {
if (!input.files || !input.files[0]) return;
const reader = new FileReader();
reader.onload = e => {
const img = document.getElementById('avatar-preview');
const ph = document.getElementById('avatar-preview-placeholder');
img.src = e.target.result;
img.style.display = '';
if (ph) ph.style.display = 'none';
document.getElementById('avatar-upload-btn').style.display = '';
};
reader.readAsDataURL(input.files[0]);
}
</script>
<?php layout_end(); ?>
+85
View File
@@ -0,0 +1,85 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM requests_stundennachweis WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$requests = $stmt->fetchAll();
$status_labels = ['pending' => 'Ausstehend', 'accepted' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Stundennachweis', 'stundennachweis');
?>
<h1 class="main-title">🕐 Stundennachweis</h1>
<p class="main-sub">Monatlichen Stundennachweis einreichen.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Nachweis erfolgreich eingereicht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div class="glass detail-card" style="margin-top:24px;max-width:480px">
<h2>Nachweis einreichen</h2>
<form action="../actions/submit-stundennachweis.php" method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="monat-grid">
<label>
<span>Monat <span style="color:#ff8080">*</span></span>
<select name="monat_m" required>
<option value=""> Monat </option>
<?php foreach (['01'=>'Januar','02'=>'Februar','03'=>'März','04'=>'April','05'=>'Mai','06'=>'Juni','07'=>'Juli','08'=>'August','09'=>'September','10'=>'Oktober','11'=>'November','12'=>'Dezember'] as $v => $l): ?>
<option value="<?= $v ?>"><?= $l ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Jahr <span style="color:#ff8080">*</span></span>
<input type="number" name="monat_j" min="2020" max="2099"
value="<?= date('Y') ?>" required style="width:100%">
</label>
</div>
<label>
<span>Nachweis <span style="color:#ff8080">*</span></span>
<input type="file" name="datei" accept=".pdf,.jpg,.jpeg,.png" required>
<span class="hint">PDF, JPG, PNG &mdash; max. 12 MB</span>
</label>
<button type="submit" class="btn" style="margin-top:6px">Einreichen</button>
</form>
</div>
<?php if (!empty($requests)): ?>
<div style="margin-top:40px">
<h2>Meine Nachweise</h2>
<table class="requests-table">
<thead>
<tr>
<th>Monat</th>
<th>Datei</th>
<th>Eingereicht</th>
<th>Status</th>
<th>Notiz</th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td style="font-weight:800"><?= e(date('F Y', strtotime($r['monat'] . '-01'))) ?></td>
<td>
<a href="upload-serve.php?file=<?= e(rawurlencode($r['filename'])) ?>&name=<?= e(rawurlencode($r['original_name'])) ?>"
style="color:var(--cyan2)"><?= e($r['original_name']) ?></a>
</td>
<td style="color:var(--muted);font-size:.9rem"><?= e(date('d.m.Y', strtotime($r['created_at']))) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_labels[$r['status']] ?? $r['status']) ?></span></td>
<td style="color:var(--muted);font-size:.9rem"><?= $r['admin_note'] !== '' ? e($r['admin_note']) : '' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php layout_end(); ?>
+21
View File
@@ -0,0 +1,21 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_admin();
$file = basename($_GET['file'] ?? '');
if ($file === '') { http_response_code(400); echo 'Ungültige Anfrage.'; exit; }
$path = dirname(__DIR__) . '/uploads/' . $file;
if (!is_file($path)) { http_response_code(404); echo 'Datei nicht gefunden.'; exit; }
$display = $_GET['name'] ?? $file;
$safe_display = addslashes($display);
$safe_encoded = rawurlencode($display);
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $safe_display . '"; filename*=UTF-8\'\'' . $safe_encoded);
header('Content-Length: ' . filesize($path));
header('X-Content-Type-Options: nosniff');
header('Cache-Control: private, no-store');
readfile($path);
exit;
+142
View File
@@ -0,0 +1,142 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM requests_urlaubsantrag WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$requests = $stmt->fetchAll();
$status_labels = ['pending' => 'Ausstehend', 'accepted' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Urlaubsantrag', 'urlaubsantrag');
?>
<style>
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:1000}
.modal-box{background:var(--glass-bg,#1e2235);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:28px 32px;max-width:480px;width:90%;position:relative}
.modal-box h3{margin:0 0 20px;font-family:'KindelSerif',Georgia,serif;font-size:1.3rem}
.modal-close{position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted,#888);line-height:1}
.modal-dl{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:.95rem}
.modal-dl dt{color:var(--muted,#888);white-space:nowrap}
.modal-dl dd{margin:0;font-weight:500}
.btn-sm{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:9px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:#fff;font-weight:600;font-size:.82rem;cursor:pointer;font:inherit;transition:background .15s}
.btn-sm:hover{background:rgba(255,255,255,.15)}
</style>
<h1 class="main-title">🌴 Urlaubsantrag</h1>
<p class="main-sub">Beantrage deinen Urlaub. Das Startdatum muss vor dem Enddatum liegen.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Antrag erfolgreich eingereicht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<!-- Details-Modal -->
<div id="urlaub-modal" class="modal-backdrop" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box glass">
<button class="modal-close" onclick="document.getElementById('urlaub-modal').style.display='none'">&times;</button>
<h3>Antrag Details</h3>
<dl class="modal-dl">
<dt>Von</dt> <dd id="md-von"></dd>
<dt>Bis</dt> <dd id="md-bis"></dd>
<dt>Vertretung</dt><dd id="md-vertretung"></dd>
<dt>Nachricht</dt> <dd id="md-nachricht"></dd>
<dt>Eingereicht</dt><dd id="md-eingereicht"></dd>
<dt>Status</dt> <dd id="md-status"></dd>
<dt>Admin-Notiz</dt><dd id="md-note"></dd>
</dl>
</div>
</div>
<div class="page-split">
<div class="page-split-list">
<h2>Meine Anträge</h2>
<?php if (empty($requests)): ?>
<p style="color:var(--muted)">Noch keine Anträge gestellt.</p>
<?php else: ?>
<table class="requests-table">
<thead>
<tr>
<th>Von</th>
<th>Bis</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td><?= e(date('d.m.Y', strtotime($r['von']))) ?></td>
<td><?= e(date('d.m.Y', strtotime($r['bis']))) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_labels[$r['status']] ?? $r['status']) ?></span></td>
<td>
<button class="btn-sm js-urlaub-details"
data-von="<?= e(date('d.m.Y', strtotime($r['von']))) ?>"
data-bis="<?= e(date('d.m.Y', strtotime($r['bis']))) ?>"
data-vertretung="<?= e($r['vertretung'] !== '' ? $r['vertretung'] : '') ?>"
data-nachricht="<?= e($r['nachricht'] !== '' ? $r['nachricht'] : '') ?>"
data-eingereicht="<?= e(date('d.m.Y', strtotime($r['created_at']))) ?>"
data-status-label="<?= e($status_labels[$r['status']] ?? $r['status']) ?>"
data-status-key="<?= e($r['status']) ?>"
data-note="<?= e($r['admin_note'] !== '' ? $r['admin_note'] : '') ?>"
>Details</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Neuer Urlaubsantrag</h2>
<form action="../actions/submit-urlaubsantrag.php" method="post">
<?= csrf_field() ?>
<label>
<span>Von <span style="color:#ff8080">*</span></span>
<input type="date" name="von" required>
</label>
<label>
<span>Bis <span style="color:#ff8080">*</span></span>
<input type="date" name="bis" required>
</label>
<label>
Vertretung
<input type="text" name="vertretung" placeholder="Name der Vertretung">
</label>
<label>
Nachricht
<textarea name="nachricht" rows="4" placeholder="Optionale Anmerkungen"></textarea>
</label>
<button type="submit" class="btn" style="margin-top:6px">Antrag einreichen</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {
var btn = e.target.closest('.js-urlaub-details');
if (!btn) return;
var d = btn.dataset;
document.getElementById('md-von').textContent = d.von;
document.getElementById('md-bis').textContent = d.bis;
document.getElementById('md-vertretung').textContent = d.vertretung;
document.getElementById('md-nachricht').textContent = d.nachricht;
document.getElementById('md-eingereicht').textContent = d.eingereicht;
document.getElementById('md-status').innerHTML =
'<span class="badge badge-' + d.statusKey + '">' + d.statusLabel + '</span>';
document.getElementById('md-note').textContent = d.note;
document.getElementById('urlaub-modal').style.display = 'flex';
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') document.getElementById('urlaub-modal').style.display = 'none';
});
</script>
<?php layout_end(); ?>
+148
View File
@@ -0,0 +1,148 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/db.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
$user_id = current_user()['id'];
$stmt = db()->prepare('SELECT * FROM requests_werben WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$user_id]);
$requests = $stmt->fetchAll();
$qual_labels = [
'1jahr' => '1 Jährige Examinierte Pflegekraft',
'3jahr' => '3 Jährige Examinierte Pflegekraft',
];
$status_labels = ['pending' => 'Ausstehend', 'accepted' => 'Akzeptiert', 'rejected' => 'Abgelehnt'];
layout_start('OMSORG Mitarbeiter werben', 'werben');
?>
<style>
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:1000}
.modal-box{background:var(--glass-bg,#1e2235);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:28px 32px;max-width:480px;width:90%;position:relative}
.modal-box h3{margin:0 0 20px;font-family:'KindelSerif',Georgia,serif;font-size:1.3rem}
.modal-close{position:absolute;top:12px;right:16px;background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted,#888);line-height:1}
.modal-dl{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;font-size:.95rem}
.modal-dl dt{color:var(--muted,#888);white-space:nowrap}
.modal-dl dd{margin:0;font-weight:500}
.btn-sm{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:9px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:#fff;font-weight:600;font-size:.82rem;cursor:pointer;font:inherit;transition:background .15s}
.btn-sm:hover{background:rgba(255,255,255,.15)}
</style>
<h1 class="main-title">🤝 Mitarbeiter werben</h1>
<p class="main-sub">Empfehle einen Kandidaten für eine offene Stelle bei OMSORG.</p>
<?php if (isset($_GET['success'])): ?>
<div class="success" style="margin-top:16px">Empfehlung erfolgreich eingereicht.</div>
<?php elseif (isset($_GET['error'])): ?>
<div class="error" style="margin-top:16px"><?= e(urldecode($_GET['error'])) ?></div>
<?php endif; ?>
<div id="werb-modal" class="modal-backdrop" onclick="if(event.target===this)this.style.display='none'">
<div class="modal-box glass">
<button class="modal-close" onclick="document.getElementById('werb-modal').style.display='none'">&times;</button>
<h3>Empfehlung Details</h3>
<dl class="modal-dl">
<dt>Name</dt> <dd id="werb-name"></dd>
<dt>E-Mail</dt> <dd id="werb-email"></dd>
<dt>Qualifikation</dt> <dd id="werb-qual"></dd>
<dt>Nachricht</dt> <dd id="werb-nachricht"></dd>
<dt>Eingereicht</dt> <dd id="werb-eingereicht"></dd>
<dt>Status</dt> <dd id="werb-status"></dd>
<dt>Admin-Notiz</dt> <dd id="werb-note"></dd>
</dl>
</div>
</div>
<div class="page-split">
<div class="page-split-list">
<h2>Meine Empfehlungen</h2>
<?php if (empty($requests)): ?>
<p style="color:var(--muted)">Noch keine Empfehlungen eingereicht.</p>
<?php else: ?>
<table class="requests-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $r): ?>
<tr>
<td style="font-weight:800"><?= e($r['name']) ?></td>
<td><span class="badge badge-<?= e($r['status']) ?>"><?= e($status_labels[$r['status']] ?? $r['status']) ?></span></td>
<td>
<button class="btn-sm js-werb-details"
data-name="<?= e($r['name']) ?>"
data-email="<?= e($r['email']) ?>"
data-qual="<?= e($qual_labels[$r['qualifikation']] ?? $r['qualifikation']) ?>"
data-nachricht="<?= e($r['nachricht'] !== '' ? $r['nachricht'] : '') ?>"
data-eingereicht="<?= e(date('d.m.Y', strtotime($r['created_at']))) ?>"
data-status-label="<?= e($status_labels[$r['status']] ?? $r['status']) ?>"
data-status-key="<?= e($r['status']) ?>"
data-note="<?= e($r['admin_note'] !== '' ? $r['admin_note'] : '') ?>"
>Details</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="page-split-form">
<div class="glass detail-card">
<h2>Kandidaten empfehlen</h2>
<form action="../actions/submit-werben.php" method="post">
<?= csrf_field() ?>
<label>
<span>Name des Kandidaten <span style="color:#ff8080">*</span></span>
<input type="text" name="name" required placeholder="Vorname Nachname">
</label>
<label>
<span>E-Mail des Kandidaten <span style="color:#ff8080">*</span></span>
<input type="email" name="email" required placeholder="kandidat@example.com">
</label>
<label>
<span>Qualifikation <span style="color:#ff8080">*</span></span>
<select name="qualifikation" required>
<option value=""> Bitte wählen </option>
<option value="1jahr">1 Jährige Examinierte Pflegekraft</option>
<option value="3jahr">3 Jährige Examinierte Pflegekraft</option>
</select>
</label>
<label>
Nachricht
<textarea name="nachricht" rows="4" placeholder="Warum empfiehlst du diese Person?"></textarea>
</label>
<button type="submit" class="btn" style="margin-top:6px">Empfehlung einreichen</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('click', function(e) {
var btn = e.target.closest('.js-werb-details');
if (!btn) return;
var d = btn.dataset;
document.getElementById('werb-name').textContent = d.name;
document.getElementById('werb-email').textContent = d.email;
document.getElementById('werb-qual').textContent = d.qual;
document.getElementById('werb-nachricht').textContent = d.nachricht;
document.getElementById('werb-eingereicht').textContent= d.eingereicht;
document.getElementById('werb-status').innerHTML =
'<span class="badge badge-' + d.statusKey + '">' + d.statusLabel + '</span>';
document.getElementById('werb-note').textContent = d.note;
document.getElementById('werb-modal').style.display = 'flex';
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') document.getElementById('werb-modal').style.display = 'none';
});
</script>
<?php layout_end(); ?>
+13
View File
@@ -0,0 +1,13 @@
const CACHE = 'omsorg-v1';
const STATIC = ['./app.css'];
self.addEventListener('install', e =>
e.waitUntil(caches.open(CACHE).then(c => c.addAll(STATIC)))
);
self.addEventListener('fetch', e => {
if (e.request.url.endsWith('.php') || e.request.method !== 'GET') return;
e.respondWith(
fetch(e.request).catch(() => caches.match(e.request))
);
});
+41
View File
@@ -0,0 +1,41 @@
<?php
ini_set('display_errors', '1');
error_reporting(E_ALL);
echo "<pre>\n";
try {
echo "1. Lade config...\n";
$c = require __DIR__ . '/lib/config.php';
echo " driver={$c['db_driver']} dsn={$c['db_dsn']}\n";
echo "2. Verbinde Datenbank...\n";
require_once __DIR__ . '/lib/db.php';
$pdo = db();
echo " Verbindung OK\n";
echo "3. Zaehle User...\n";
$count = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
echo " $count User gefunden\n";
if ($count === 0) {
echo "4. Erstelle Admin-User...\n";
$hash = password_hash('adminadmin', PASSWORD_BCRYPT, ['cost' => 12]);
$stmt = $pdo->prepare(
'INSERT INTO users (username, name, role, password_hash, active, created_at)
VALUES (?, ?, ?, ?, 1, ?)'
);
$stmt->execute(['admin', 'Administrator', 'admin', $hash, date('c')]);
echo " Admin erstellt: username=admin password=adminadmin\n";
} else {
echo "4. DB hat bereits User — kein Admin eingefuegt.\n";
}
echo "\nSetup erfolgreich abgeschlossen.\n";
} catch (Throwable $e) {
echo "\nFEHLER: " . $e->getMessage() . "\n";
echo "Datei: " . $e->getFile() . " Zeile " . $e->getLine() . "\n";
echo "\nStack trace:\n" . $e->getTraceAsString() . "\n";
}
echo "</pre>\n";