diff --git a/DeskHub-Webseite/.gitignore b/DeskHub-Webseite/.gitignore
new file mode 100644
index 00000000..5f45b935
--- /dev/null
+++ b/DeskHub-Webseite/.gitignore
@@ -0,0 +1,30 @@
+# Laufzeitdaten und Geheimnisse gehören nie ins Repository
+/config/config.php
+/database/*.sqlite
+/database/*.sqlite-*
+/database/*.db
+/backups/*
+!/backups/.htaccess
+/uploads/*
+!/uploads/.htaccess
+/plugins/*
+!/plugins/.htaccess
+!/plugins/README.md
+/assets/themes/*
+/app_downloads/*
+!/app_downloads/.htaccess
+!/app_downloads/index.html
+/install/.installed
+/cron/*.log
+*.log
+*.bak
+.env
+*.pem
+# Entwicklung
+node_modules/
+/desktop/dist/
+/mobile/build/
+/mobile/.gradle/
+/plugin-webseite/schluessel/
+/plugin-webseite/storage/
+/plugin-webseite/config.php
diff --git a/DeskHub-Webseite/.htaccess b/DeskHub-Webseite/.htaccess
new file mode 100644
index 00000000..d16f3425
--- /dev/null
+++ b/DeskHub-Webseite/.htaccess
@@ -0,0 +1,41 @@
+
+# ── Bot & Scanner Schutz ──────────────────────────────────────────────────
+# Bekannte böswillige Bots blockieren
+RewriteCond %{HTTP_USER_AGENT} (masscan|nikto|sqlmap|nmap|zgrab|python-requests/2\.2[0-9]|scrapy|dirbuster|gobuster|nuclei) [NC]
+RewriteRule ^ - [F,L]
+
+# Leere User-Agents blockieren
+RewriteCond %{HTTP_USER_AGENT} ^-?$
+RewriteRule ^ - [F,L]
+
+# Direkt-IP-Zugriff ohne Host verhindern (optional)
+# RewriteCond %{HTTP_HOST} !^yourdomain\.com$
+# RewriteRule ^ - [F,L]
+
+# Clickjacking-Schutz
+Header always set X-Frame-Options "SAMEORIGIN"
+Header always set X-Content-Type-Options "nosniff"
+
+# Schützt interne Verzeichnisse vor direktem Web-Zugriff.
+# Funktioniert mit Apache (mod_rewrite/mod_authz aktiv). Bei nginx siehe README.md.
+
+RewriteEngine On
+RewriteCond %{REQUEST_URI} ^/(includes|config|database|cron)/ [NC]
+RewriteRule ^ - [F,L]
+
+# Entwicklungs- und Hilfsordner sowie interne Dateien gehören nicht ins Web (Tests, Werkzeuge, Quelltexte der Apps, Paketquellen)
+RewriteCond %{REQUEST_URI} ^/(tests|tools|docs|desktop|mobile|theme-packages|vorschaubilder|plugin-webseite|\.git|\.gitea|\.github|backups|plugins/[^/]+/(lib|migrations))(/|$) [NC]
+RewriteRule ^ - [F,L]
+RewriteCond %{REQUEST_URI} \.(sqlite3?|db|sql|log|bak|old|orig|swp|md|sh|bat|ps1|pem|key|env|ini|lock|dist)$ [NC,OR]
+RewriteCond %{REQUEST_URI} ^/(offen\.txt|install\.sh|install_windows\.bat|composer\.(json|lock)|package(-lock)?\.json)$ [NC]
+RewriteRule ^ - [F,L]
+
+# version.php nur intern zugänglich
+RewriteCond %{REQUEST_URI} ^/version\.php$ [NC]
+RewriteRule ^ - [F,L]
+
+
+ php_flag display_errors off
+
+
+Options -Indexes
diff --git a/DeskHub-Webseite/api/.htaccess b/DeskHub-Webseite/api/.htaccess
new file mode 100644
index 00000000..31815df5
--- /dev/null
+++ b/DeskHub-Webseite/api/.htaccess
@@ -0,0 +1,5 @@
+Options -Indexes
+
+
+ Header always set Cache-Control "no-store"
+
diff --git a/DeskHub-Webseite/api/public/v1/tickets.php b/DeskHub-Webseite/api/public/v1/tickets.php
new file mode 100644
index 00000000..2bfe3ea4
--- /dev/null
+++ b/DeskHub-Webseite/api/public/v1/tickets.php
@@ -0,0 +1,73 @@
+ ['code' => $code, 'message' => $message]], JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+if (!in_array($method, ['GET', 'POST'], true)) {
+ header('Allow: GET, POST');
+ public_api_fail(405, 'METHOD_NOT_ALLOWED', 'Erlaubt sind GET und POST.');
+}
+
+$key = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? ''));
+if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1];
+$pdo = get_db();
+$apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null;
+if (!$apiKey) public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).');
+
+if ($method === 'POST') {
+ if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.');
+ // Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute
+ $recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?");
+ $recent->execute([date('Y-m-d H:i:s', time() - 60)]);
+ if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.');
+
+ $raw = file_get_contents('php://input') ?: '';
+ if (strlen($raw) > 200000) public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.');
+ $body = json_decode($raw, true);
+ if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.');
+ try {
+ $created = public_ticket_create($pdo, $body, client_ip());
+ } catch (AdminModelException $e) {
+ public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage());
+ }
+ http_response_code(201);
+ echo json_encode(['number' => $created['number'], 'status' => 'open'], JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+// GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden)
+if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.');
+$number = trim((string) ($_GET['number'] ?? ''));
+$email = strtolower(trim((string) ($_GET['email'] ?? '')));
+if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Parameter number und email sind erforderlich.');
+$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ?');
+$stmt->execute([$number, $email]);
+$ticket = $stmt->fetch();
+if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.');
+$threads = $pdo->prepare('SELECT author_type, message, created_at FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 0 ORDER BY created_at, id');
+$threads->execute([$ticket['id']]);
+echo json_encode(['ticket' => [
+ 'number' => $ticket['ticket_number'], 'subject' => $ticket['subject'], 'status' => $ticket['status'],
+ 'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'],
+ 'messages' => array_map(fn($t) => ['author' => $t['author_type'] === 'customer' ? 'customer' : 'staff', 'message' => trim(strip_tags((string) $t['message'])), 'createdAt' => $t['created_at']], $threads->fetchAll()),
+]], JSON_UNESCAPED_UNICODE);
diff --git a/DeskHub-Webseite/api/staff/v1/activity.php b/DeskHub-Webseite/api/staff/v1/activity.php
new file mode 100644
index 00000000..a44a8cc8
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/activity.php
@@ -0,0 +1,41 @@
+ 0, 'newUnassigned' => 0, 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]);
+}
+if (strtotime($since) === false) {
+ api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.');
+}
+$sinceSql = date('Y-m-d H:i:s', strtotime($since));
+
+$stmt = $pdo->prepare(
+ "SELECT COUNT(*) FROM tickets
+ WHERE assigned_staff_id = ? AND updated_at > ? AND status NOT IN ('resolved','closed')"
+);
+$stmt->execute([$staff['id'], $sinceSql]);
+$newReplies = (int) $stmt->fetchColumn();
+
+$departmentIds = staff_department_ids($staff);
+[$deptFilter, $deptParams] = dept_filter_sql($departmentIds, 'department_id');
+$stmt = $pdo->prepare(
+ "SELECT COUNT(*) FROM tickets
+ WHERE assigned_staff_id IS NULL AND created_at > ?{$deptFilter}"
+);
+$stmt->execute([$sinceSql, ...$deptParams]);
+$newUnassigned = (int) $stmt->fetchColumn();
+
+api_json([
+ 'newReplies' => $newReplies,
+ 'newUnassigned' => $newUnassigned,
+ 'serverTime' => db_now(),
+ 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [],
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/audit_log.php b/DeskHub-Webseite/api/staff/v1/audit_log.php
new file mode 100644
index 00000000..c702da66
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/audit_log.php
@@ -0,0 +1,36 @@
+query('SELECT COUNT(*) FROM admin_audit_log')->fetchColumn();
+$stmt = $pdo->prepare(
+ 'SELECT al.*, s.name AS staff_name FROM admin_audit_log al
+ LEFT JOIN staff s ON s.id = al.staff_id
+ ORDER BY al.created_at DESC LIMIT ? OFFSET ?'
+);
+$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
+$stmt->bindValue(2, $offset, PDO::PARAM_INT);
+$stmt->execute();
+$entries = $stmt->fetchAll();
+
+api_json([
+ 'items' => array_map(fn(array $e) => [
+ 'id' => (int) $e['id'],
+ 'staffName' => $e['staff_name'],
+ 'action' => $e['action'],
+ 'createdAt' => $e['created_at'],
+ ], $entries),
+ 'page' => $page,
+ 'perPage' => $perPage,
+ 'total' => $total,
+ 'hasMore' => $offset + count($entries) < $total,
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/availability.php b/DeskHub-Webseite/api/staff/v1/availability.php
new file mode 100644
index 00000000..0e358242
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/availability.php
@@ -0,0 +1,68 @@
+query(
+ 'SELECT s.id, s.name, s.is_on_vacation, sa.status, sa.note, sa.updated_at
+ FROM staff s
+ LEFT JOIN staff_availability sa ON sa.staff_id = s.id
+ WHERE s.is_active = 1
+ ORDER BY s.name'
+ )->fetchAll();
+
+ $me = null;
+ $team = [];
+ foreach ($agents as $a) {
+ $entry = [
+ 'id' => (int) $a['id'],
+ 'name' => $a['name'],
+ 'status' => $a['is_on_vacation'] ? 'offline' : ($a['status'] ?: 'offline'),
+ 'note' => $a['is_on_vacation'] ? 'Urlaub' : ($a['note'] ?? ''),
+ 'isOnVacation' => (bool) $a['is_on_vacation'],
+ 'updatedAt' => $a['updated_at'],
+ ];
+ $team[] = $entry;
+ if ((int) $a['id'] === (int) $staff['id']) {
+ // "Mein Status" fällt (wie im Web) auf "available" zurück, solange nie gesetzt –
+ // reine Anzeige-Vorauswahl, ändert nichts an der "Offline"-Team-Konvention oben.
+ $me = ['status' => $a['status'] ?: 'available', 'note' => $a['note'] ?? ''];
+ }
+ }
+
+ api_json(['myStatus' => $me['status'] ?? 'available', 'myNote' => $me['note'] ?? '', 'team' => $team]);
+}
+
+$body = api_request_body();
+$newStatus = (string) ($body['status'] ?? '');
+$note = trim((string) ($body['note'] ?? ''));
+
+if (!in_array($newStatus, AVAILABILITY_STATUSES, true)) {
+ api_error(422, 'INVALID_STATUS', 'Ungültiger Status.');
+}
+
+$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+$sql = $drv === 'sqlite'
+ ? 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?)
+ ON CONFLICT(staff_id) DO UPDATE SET status=excluded.status, note=excluded.note, updated_at=excluded.updated_at'
+ : 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?)
+ ON DUPLICATE KEY UPDATE status=VALUES(status), note=VALUES(note), updated_at=VALUES(updated_at)';
+$pdo->prepare($sql)->execute([$staff['id'], $newStatus, $note ?: null, db_now()]);
+
+api_json(['ok' => true, 'status' => $newStatus]);
diff --git a/DeskHub-Webseite/api/staff/v1/banlist.php b/DeskHub-Webseite/api/staff/v1/banlist.php
new file mode 100644
index 00000000..fe98099c
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/banlist.php
@@ -0,0 +1,100 @@
+query(
+ 'SELECT b.*, s.name AS added_by_name FROM banlist b LEFT JOIN staff s ON s.id = b.created_by ORDER BY b.created_at DESC'
+ )->fetchAll();
+
+ api_json([
+ 'items' => array_map(fn(array $b) => [
+ 'id' => (int) $b['id'],
+ 'matchType' => $b['match_type'],
+ 'value' => $b['value'],
+ 'reason' => $b['reason'],
+ 'addedByName' => $b['added_by_name'],
+ 'createdAt' => $b['created_at'],
+ ], $entries),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+/** Schließt alle offenen Tickets, die zur gerade gesperrten E-Mail/Domain passen. */
+function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void
+{
+ $col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email');
+ $pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
+ ->execute([db_now(), db_now(), $value]);
+}
+
+if ($action === 'create') {
+ $matchType = ($body['matchType'] ?? 'email') === 'domain' ? 'domain' : 'email';
+ $value = trim(strtolower((string) ($body['value'] ?? '')));
+ $reason = trim((string) ($body['reason'] ?? ''));
+
+ if ($value === '') {
+ api_error(422, 'VALUE_REQUIRED', 'Bitte einen Wert angeben.');
+ }
+ if ($matchType === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
+ api_error(422, 'INVALID_EMAIL', 'Ungültige E-Mail-Adresse.');
+ }
+ if ($matchType === 'domain' && (!str_contains($value, '.') || str_contains($value, '@'))) {
+ api_error(422, 'INVALID_DOMAIN', 'Nur Domain angeben, z.B. spam.de (ohne @).');
+ }
+
+ $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
+ $chk->execute([$value]);
+ if (!$chk->fetchColumn()) {
+ $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
+ ->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]);
+ api_close_banned_tickets($pdo, $matchType, $value);
+ audit_log($staff['id'], "Sperrliste: {$value} hinzugefügt ({$matchType}, Mobile App)");
+ }
+ api_json(['ok' => true]);
+}
+
+if ($action === 'bulk_add') {
+ $raw = trim((string) ($body['values'] ?? ''));
+ $reason = trim((string) ($body['reason'] ?? ''));
+ $lines = array_filter(array_map('trim', preg_split('/[\r\n,;]+/', $raw)));
+ $added = 0;
+ $skipped = 0;
+ foreach ($lines as $val) {
+ $val = strtolower($val);
+ $type = str_contains($val, '@') ? 'email' : 'domain';
+ if ($type === 'email' && !filter_var($val, FILTER_VALIDATE_EMAIL)) { $skipped++; continue; }
+ if ($type === 'domain' && !str_contains($val, '.')) { $skipped++; continue; }
+ $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
+ $chk->execute([$val]);
+ if ($chk->fetchColumn()) { $skipped++; continue; }
+ $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
+ ->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]);
+ api_close_banned_tickets($pdo, $type, $val);
+ $added++;
+ }
+ audit_log($staff['id'], "Sperrliste Massen-Import: {$added} hinzugefügt, {$skipped} übersprungen (Mobile App)");
+ api_json(['added' => $added, 'skipped' => $skipped]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $val = $pdo->prepare('SELECT value FROM banlist WHERE id=?');
+ $val->execute([$id]);
+ $v = $val->fetchColumn();
+ if ($v === false) {
+ api_error(404, 'ENTRY_NOT_FOUND', 'Der Eintrag wurde nicht gefunden.');
+ }
+ $pdo->prepare('DELETE FROM banlist WHERE id=?')->execute([$id]);
+ audit_log($staff['id'], "Sperrliste: {$v} entfernt (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/business_hours.php b/DeskHub-Webseite/api/staff/v1/business_hours.php
new file mode 100644
index 00000000..fcf3c37c
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/business_hours.php
@@ -0,0 +1,77 @@
+query('SELECT * FROM business_hours')->fetchAll() as $r) {
+ $hoursByWeekday[(int) $r['weekday']] = $r;
+ }
+ $holidays = $pdo->query('SELECT * FROM business_holidays ORDER BY holiday_date')->fetchAll();
+
+ api_json([
+ 'enabled' => get_setting('business_hours_enabled', '0') === '1',
+ 'hours' => array_map(function (int $wd) use ($hoursByWeekday) {
+ $h = $hoursByWeekday[$wd] ?? ['is_open' => 0, 'open_time' => '09:00:00', 'close_time' => '17:00:00'];
+ return [
+ 'weekday' => $wd,
+ 'isOpen' => (bool) $h['is_open'],
+ 'openTime' => substr($h['open_time'], 0, 5),
+ 'closeTime' => substr($h['close_time'], 0, 5),
+ ];
+ }, BUSINESS_WEEKDAYS),
+ 'holidays' => array_map(fn(array $h) => [
+ 'id' => (int) $h['id'],
+ 'date' => $h['holiday_date'],
+ 'name' => $h['name'],
+ ], $holidays),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'save_hours') {
+ set_setting('business_hours_enabled', !empty($body['enabled']) ? '1' : '0');
+ $incoming = (array) ($body['hours'] ?? []);
+ $byWeekday = [];
+ foreach ($incoming as $entry) {
+ $byWeekday[(int) ($entry['weekday'] ?? -1)] = $entry;
+ }
+ $stmt = $pdo->prepare('UPDATE business_hours SET is_open=?, open_time=?, close_time=? WHERE weekday=?');
+ foreach (BUSINESS_WEEKDAYS as $wd) {
+ $entry = $byWeekday[$wd] ?? null;
+ $isOpen = !empty($entry['isOpen']) ? 1 : 0;
+ $openTime = trim((string) ($entry['openTime'] ?? '09:00')) . ':00';
+ $closeTime = trim((string) ($entry['closeTime'] ?? '17:00')) . ':00';
+ $stmt->execute([$isOpen, $openTime, $closeTime, $wd]);
+ }
+ audit_log($staff['id'], 'Geschäftszeiten aktualisiert (Mobile App)');
+ api_json(['ok' => true]);
+}
+
+if ($action === 'add_holiday') {
+ $date = trim((string) ($body['date'] ?? ''));
+ $name = trim((string) ($body['name'] ?? ''));
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) api_error(422, 'INVALID_DATE', 'Bitte ein gültiges Datum angeben.');
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen für den Feiertag angeben.');
+
+ $pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)')->execute([$date, $name]);
+ audit_log($staff['id'], "Feiertag hinzugefügt: {$name} ({$date}) (Mobile App)");
+ api_json(['id' => (int) $pdo->lastInsertId()]);
+}
+
+if ($action === 'delete_holiday') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('DELETE FROM business_holidays WHERE id = ?')->execute([$id]);
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/canned_responses.php b/DeskHub-Webseite/api/staff/v1/canned_responses.php
new file mode 100644
index 00000000..7f509c2a
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/canned_responses.php
@@ -0,0 +1,40 @@
+prepare(
+ "SELECT cr.id, cr.title, cr.body, cr.department_id
+ FROM canned_responses cr
+ WHERE 1=1{$deptFilter}
+ ORDER BY cr.title"
+);
+$stmt->execute($deptParams);
+
+$responses = array_map(fn(array $row) => [
+ 'id' => (int) $row['id'],
+ 'title' => $row['title'],
+ 'body' => api_plain_text($row['body']),
+ 'departmentId' => $row['department_id'] !== null ? (int) $row['department_id'] : null,
+], $stmt->fetchAll());
+
+api_json(['items' => $responses]);
diff --git a/DeskHub-Webseite/api/staff/v1/chat.php b/DeskHub-Webseite/api/staff/v1/chat.php
new file mode 100644
index 00000000..5d744d1c
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/chat.php
@@ -0,0 +1,105 @@
+prepare('SELECT id, name FROM staff WHERE id = ?');
+ $withStmt->execute([$withId]);
+ $withStaff = $withStmt->fetch();
+ if (!$withStaff) {
+ api_error(404, 'STAFF_NOT_FOUND', 'Der Mitarbeiter wurde nicht gefunden.');
+ }
+
+ $pdo->prepare('UPDATE staff_chat_messages SET is_read = 1 WHERE from_staff_id = ? AND to_staff_id = ?')
+ ->execute([$withId, $staff['id']]);
+
+ $cStmt = $pdo->prepare(
+ 'SELECT m.*, sf.name AS from_name
+ FROM staff_chat_messages m
+ JOIN staff sf ON sf.id = m.from_staff_id
+ WHERE (m.from_staff_id = ? AND m.to_staff_id = ?)
+ OR (m.from_staff_id = ? AND m.to_staff_id = ?)
+ ORDER BY m.created_at ASC LIMIT 300'
+ );
+ $cStmt->execute([$staff['id'], $withId, $withId, $staff['id']]);
+ $conversation = $cStmt->fetchAll();
+
+ api_json([
+ 'with' => ['id' => (int) $withStaff['id'], 'name' => $withStaff['name']],
+ 'messages' => array_map(fn(array $m) => [
+ 'id' => (int) $m['id'],
+ 'fromStaffId' => (int) $m['from_staff_id'],
+ 'fromName' => $m['from_name'],
+ 'message' => $m['message'],
+ 'createdAt' => $m['created_at'],
+ ], $conversation),
+ ]);
+ }
+
+ $allStaff = $pdo->prepare('SELECT id, name FROM staff WHERE is_active = 1 AND id != ? ORDER BY name');
+ $allStaff->execute([$staff['id']]);
+ $contacts = $allStaff->fetchAll();
+
+ $unreadStmt = $pdo->prepare(
+ 'SELECT from_staff_id, COUNT(*) AS cnt FROM staff_chat_messages WHERE to_staff_id = ? AND is_read = 0 GROUP BY from_staff_id'
+ );
+ $unreadStmt->execute([$staff['id']]);
+ $unreadByStaff = [];
+ foreach ($unreadStmt->fetchAll() as $row) {
+ $unreadByStaff[(int) $row['from_staff_id']] = (int) $row['cnt'];
+ }
+
+ $lastStmt = $pdo->prepare(
+ "SELECT CASE WHEN from_staff_id = ? THEN to_staff_id ELSE from_staff_id END AS other_id,
+ MAX(created_at) AS last_at
+ FROM staff_chat_messages
+ WHERE from_staff_id = ? OR to_staff_id = ?
+ GROUP BY other_id"
+ );
+ $lastStmt->execute([$staff['id'], $staff['id'], $staff['id']]);
+ $lastByStaff = [];
+ foreach ($lastStmt->fetchAll() as $row) {
+ $lastByStaff[(int) $row['other_id']] = $row['last_at'];
+ }
+
+ api_json([
+ 'contacts' => array_map(fn(array $s) => [
+ 'id' => (int) $s['id'],
+ 'name' => $s['name'],
+ 'unreadCount' => $unreadByStaff[(int) $s['id']] ?? 0,
+ 'lastMessageAt' => $lastByStaff[(int) $s['id']] ?? null,
+ ], $contacts),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'send') {
+ $toId = (int) ($body['toId'] ?? 0);
+ $message = trim((string) ($body['message'] ?? ''));
+
+ if ($toId === 0) api_error(422, 'RECIPIENT_REQUIRED', 'Bitte einen Empfänger angeben.');
+ if ($message === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.');
+ if (strlen($message) > 5000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
+
+ $toCheck = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
+ $toCheck->execute([$toId]);
+ if (!(bool) $toCheck->fetchColumn()) {
+ api_error(422, 'RECIPIENT_NOT_FOUND', 'Der Empfänger wurde nicht gefunden.');
+ }
+
+ $pdo->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, created_at) VALUES (?,?,?,?)')
+ ->execute([$staff['id'], $toId, $message, db_now()]);
+ api_json(['id' => (int) $pdo->lastInsertId()]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/common.php b/DeskHub-Webseite/api/staff/v1/common.php
new file mode 100644
index 00000000..f24d5b38
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/common.php
@@ -0,0 +1,346 @@
+getMessage());
+ api_error(500, 'INTERNAL_ERROR', 'Die Anfrage konnte nicht verarbeitet werden.');
+});
+
+function api_json(array $payload, int $status = 200): never
+{
+ http_response_code($status);
+ echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+function api_error(int $status, string $code, string $message, array $details = []): never
+{
+ api_json(['error' => ['code' => $code, 'message' => $message, 'details' => $details]], $status);
+}
+
+function api_require_method(string ...$methods): void
+{
+ if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', $methods, true)) {
+ header('Allow: ' . implode(', ', $methods));
+ api_error(405, 'METHOD_NOT_ALLOWED', 'Diese HTTP-Methode wird nicht unterstützt.');
+ }
+}
+
+function api_request_body(): array
+{
+ $length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
+ if ($length > 1024 * 1024) {
+ api_error(413, 'PAYLOAD_TOO_LARGE', 'Die Anfrage ist zu groß.');
+ }
+ $raw = file_get_contents('php://input');
+ if ($raw === false || trim($raw) === '') {
+ return [];
+ }
+ try {
+ $body = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
+ } catch (JsonException $e) {
+ api_error(400, 'INVALID_JSON', 'Der JSON-Inhalt ist ungültig.');
+ }
+ if (!is_array($body)) {
+ api_error(400, 'INVALID_JSON', 'Ein JSON-Objekt wird erwartet.');
+ }
+ return $body;
+}
+
+function api_require_https(): void
+{
+ $https = request_is_https();
+ $local = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
+ $allowInsecure = getenv('DESKHUB_API_ALLOW_INSECURE') === '1';
+ if (!$https && !$local && !$allowInsecure) {
+ api_error(400, 'HTTPS_REQUIRED', 'Die mobile Anmeldung ist ausschließlich über HTTPS erlaubt.');
+ }
+}
+
+function api_authorization_header(): string
+{
+ $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
+ if ($header === '' && function_exists('getallheaders')) {
+ $headers = getallheaders();
+ $header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
+ }
+ return trim($header);
+}
+
+function api_bearer_token(): ?string
+{
+ $header = api_authorization_header();
+ if (!preg_match('/^Bearer\s+([A-Za-z0-9_-]{40,})$/i', $header, $matches)) {
+ return null;
+ }
+ return $matches[1];
+}
+
+function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array
+{
+ $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
+ $days = max(1, min(365, (int) get_setting('mobile_api_token_days', '30')));
+ $expiresAt = date('Y-m-d H:i:s', time() + ($days * 86400));
+ $deviceName = trim((string) $deviceName);
+ if ($deviceName === '') {
+ $deviceName = null;
+ } else {
+ $deviceName = mb_substr($deviceName, 0, 120);
+ }
+
+ $pdo->prepare(
+ 'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at)
+ VALUES (?,?,?,?,?)'
+ )->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now()]);
+
+ return ['token' => $token, 'expires_at' => $expiresAt];
+}
+
+function api_require_staff(): array
+{
+ $token = api_bearer_token();
+ if ($token === null) {
+ api_error(401, 'UNAUTHENTICATED', 'Ein gültiges Bearer-Token ist erforderlich.');
+ }
+
+ $pdo = get_db();
+ $stmt = $pdo->prepare(
+ 'SELECT s.*, mt.id AS mobile_token_id, mt.last_used_at AS mobile_token_last_used_at
+ FROM mobile_api_tokens mt
+ JOIN staff s ON s.id = mt.staff_id
+ WHERE mt.token_hash = ? AND mt.revoked_at IS NULL AND mt.expires_at > ? AND s.is_active = 1
+ LIMIT 1'
+ );
+ $stmt->execute([hash('sha256', $token), db_now()]);
+ $staff = $stmt->fetch();
+ if (!$staff) {
+ api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.');
+ }
+
+ $lastUsed = $staff['mobile_token_last_used_at'] ?? null;
+ if (!$lastUsed || strtotime($lastUsed) < time() - 300) {
+ $pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
+ ->execute([db_now(), $staff['mobile_token_id']]);
+ }
+
+ // "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php):
+ // auch App-Nutzung hält die automatischen Hintergrund-Jobs am Laufen.
+ pseudo_cron_schedule_background();
+
+ return $staff;
+}
+
+function api_staff_payload(array $staff): array
+{
+ $permissionNames = array_merge([
+ 'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket',
+ 'create_ticket', 'view_reports',
+ ], array_keys(admin_section_permissions()));
+ $permissions = [];
+ foreach ($permissionNames as $permission) {
+ $permissions[$permission] = has_permission($staff, $permission);
+ }
+ return [
+ 'id' => (int) $staff['id'],
+ 'name' => $staff['name'],
+ 'email' => $staff['email'],
+ 'role' => $staff['role'],
+ 'permissions' => $permissions,
+ ];
+}
+
+/** $departmentId: Abteilung des Tickets – dort kann eine eigene Rolle (erweiterter Zugriff) gelten. */
+function api_require_permission(array $staff, string $permission, ?int $departmentId = null): void
+{
+ if (!has_permission($staff, $permission, $departmentId)) {
+ api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.');
+ }
+}
+
+function api_require_admin(array $staff): void
+{
+ if ($staff['role'] !== 'admin') {
+ api_error(403, 'FORBIDDEN', 'Diese Funktion ist nur für Administratoren verfügbar.');
+ }
+}
+
+/** Wie api_require_admin(), lässt aber Agenten mit passend zugewiesener Co-Admin-Berechtigung durch. */
+function api_require_admin_or_permission(array $staff, string $permission): void
+{
+ if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) {
+ api_error(403, 'FORBIDDEN', 'Für diese Funktion fehlt die erforderliche Berechtigung.');
+ }
+}
+
+function api_find_ticket(PDO $pdo, array $staff, string $number): array
+{
+ $stmt = $pdo->prepare(
+ 'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
+ s.name AS staff_name
+ FROM tickets t
+ JOIN priorities p ON p.id = t.priority_id
+ JOIN departments d ON d.id = t.department_id
+ LEFT JOIN staff s ON s.id = t.assigned_staff_id
+ WHERE t.ticket_number = ?'
+ );
+ $stmt->execute([$number]);
+ $ticket = $stmt->fetch();
+ if (!$ticket) {
+ api_error(404, 'TICKET_NOT_FOUND', 'Das Ticket wurde nicht gefunden.');
+ }
+ $departmentIds = staff_department_ids($staff);
+ if ($departmentIds !== null && !in_array((int) $ticket['department_id'], $departmentIds, true)) {
+ api_error(403, 'TICKET_FORBIDDEN', 'Auf dieses Ticket besteht kein Zugriff.');
+ }
+ return $ticket;
+}
+
+function api_plain_text(?string $html): string
+{
+ $text = preg_replace('/
/i', "\n", (string) $html);
+ $text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
+ return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+}
+
+function api_ticket_summary(array $ticket): array
+{
+ return [
+ 'id' => (int) $ticket['id'],
+ 'number' => $ticket['ticket_number'],
+ 'subject' => $ticket['subject'],
+ 'requesterName' => $ticket['requester_name'],
+ 'requesterEmail' => $ticket['requester_email'],
+ 'status' => $ticket['status'],
+ 'priority' => [
+ 'id' => (int) $ticket['priority_id'],
+ 'name' => $ticket['priority_name'],
+ 'color' => $ticket['priority_color'],
+ ],
+ 'department' => [
+ 'id' => (int) $ticket['department_id'],
+ 'name' => $ticket['department_name'],
+ ],
+ 'assignedStaff' => $ticket['assigned_staff_id'] === null ? null : [
+ 'id' => (int) $ticket['assigned_staff_id'],
+ 'name' => $ticket['staff_name'],
+ ],
+ 'isOverdue' => !empty($ticket['resolution_due_at'])
+ && strtotime($ticket['resolution_due_at']) < time()
+ && !in_array($ticket['status'], ['resolved', 'closed'], true),
+ // Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min
+ // (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im
+ // Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus
+ // den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format).
+ 'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null,
+ 'isSpam' => !empty($ticket['is_spam']),
+ 'createdAt' => $ticket['created_at'],
+ 'updatedAt' => $ticket['updated_at'],
+ ];
+}
+
+function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus): int
+{
+ $permission = $type === 'note' ? 'post_note' : 'reply_ticket';
+ api_require_permission($staff, $permission, (int) $ticket['department_id']);
+ if (!in_array($type, ['reply', 'note'], true)) {
+ api_error(422, 'INVALID_MESSAGE_TYPE', 'Der Nachrichtentyp ist ungültig.');
+ }
+
+ $message = sanitize_staff_html(resolve_canned_vars(trim($rawMessage), $ticket, $staff));
+ if (trim(strip_tags($message)) === '') {
+ api_error(422, 'MESSAGE_REQUIRED', 'Bitte geben Sie eine Nachricht ein.');
+ }
+ if (strlen($message) > 100000) {
+ api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
+ }
+
+ $isNote = $type === 'note';
+
+ // Live-Chat-Sperre (siehe includes/functions.php): eine öffentliche Antwort auf
+ // einen Chat, der gerade frisch von einem anderen Mitarbeiter (egal ob Web oder
+ // App) "besessen" wird, wird abgelehnt – interne Notizen bleiben immer erlaubt,
+ // die sind ja ohnehin nur für Mitarbeiter sichtbar und kein Konflikt für den Kunden.
+ if (!$isNote && $ticket['source'] === 'chat') {
+ $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
+ if ($lockedByOtherName) {
+ api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
+ }
+ }
+
+ // Generelle Ticket-Sperre für alle anderen Tickets (siehe ticket.php – wer die GET-
+ // Anfrage erfolgreich gemacht hat, besitzt die Sperre bereits; das hier fängt nur
+ // den seltenen Fall ab, dass sie zwischen Laden und Absenden abgelaufen ist bzw. ein
+ // anderer Mitarbeiter sie in der Zwischenzeit übernommen hat).
+ if ($ticket['source'] !== 'chat') {
+ $lockedByOtherName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo);
+ if ($lockedByOtherName) {
+ api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByOtherName\" bearbeitet.");
+ }
+ }
+
+ $newStatus = $setStatus ?: 'answered';
+ if (!$isNote) {
+ if (!in_array($newStatus, MOBILE_API_STATUSES, true)) {
+ api_error(422, 'INVALID_STATUS', 'Der Zielstatus ist ungültig.');
+ }
+ if (in_array($newStatus, ['resolved', 'closed'], true)) {
+ api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
+ }
+ }
+ $now = db_now();
+ $pdo->beginTransaction();
+ try {
+ $stmt = $pdo->prepare(
+ 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
+ VALUES (?,?,?,?,?,?)'
+ );
+ $stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote ? 1 : 0, $now]);
+ $threadId = (int) $pdo->lastInsertId();
+
+ if ($isNote) {
+ save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
+ log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Interne Notiz hinzugefügt (Mobile App)');
+ } else {
+ $firstResponse = empty($ticket['first_responded_at']);
+ $autoAssign = empty($ticket['assigned_staff_id']);
+ $sql = 'UPDATE tickets SET status = ?, updated_at = ?'
+ . ($firstResponse ? ', first_responded_at = ?' : '')
+ . ($autoAssign ? ', assigned_staff_id = ?' : '')
+ . ' WHERE id = ?';
+ $args = [$newStatus, $now];
+ if ($firstResponse) $args[] = $now;
+ if ($autoAssign) $args[] = $staff['id'];
+ $args[] = $ticket['id'];
+ $pdo->prepare($sql)->execute($args);
+ save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
+ log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
+ }
+ $pdo->commit();
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ throw $e;
+ }
+
+ if (!$isNote) {
+ $signature = trim((string) ($staff['signature'] ?? ''));
+ if ($signature === '') {
+ $signatureStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
+ $signatureStmt->execute([$ticket['department_id']]);
+ $signature = (string) $signatureStmt->fetchColumn();
+ }
+ $collabStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
+ $collabStmt->execute([$ticket['id']]);
+ notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
+ }
+ webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => $isNote]);
+ return $threadId;
+}
diff --git a/DeskHub-Webseite/api/staff/v1/customer.php b/DeskHub-Webseite/api/staff/v1/customer.php
new file mode 100644
index 00000000..a0bc32ce
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/customer.php
@@ -0,0 +1,71 @@
+prepare(
+ 'SELECT u.id, u.name, u.email, u.created_at, o.name AS organization_name
+ FROM users u LEFT JOIN organizations o ON o.id = u.organization_id
+ WHERE u.id = ?'
+ );
+ $stmt->execute([$userId]);
+ $customer = $stmt->fetch();
+ if (!$customer) {
+ api_error(404, 'CUSTOMER_NOT_FOUND', 'Der Kunde wurde nicht gefunden.');
+ }
+ $email = $customer['email'];
+ $isGuest = false;
+} else {
+ if ($email === '') {
+ api_error(422, 'CUSTOMER_IDENTIFIER_REQUIRED', 'Bitte userId oder email angeben.');
+ }
+ $ticketStmt = $pdo->prepare(
+ 'SELECT MAX(requester_name) AS name, MIN(created_at) AS created_at
+ FROM tickets WHERE requester_email = ? AND user_id IS NULL'
+ );
+ $ticketStmt->execute([$email]);
+ $guestRow = $ticketStmt->fetch();
+ if (!$guestRow || $guestRow['name'] === null) {
+ api_error(404, 'CUSTOMER_NOT_FOUND', 'Der Kunde wurde nicht gefunden.');
+ }
+ $customer = [
+ 'id' => null,
+ 'name' => $guestRow['name'],
+ 'email' => $email,
+ 'created_at' => $guestRow['created_at'],
+ 'organization_name' => null,
+ ];
+ $isGuest = true;
+}
+
+$ticketStmt = $pdo->prepare(
+ "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
+ s.name AS staff_name
+ FROM tickets t
+ JOIN priorities p ON p.id = t.priority_id
+ JOIN departments d ON d.id = t.department_id
+ LEFT JOIN staff s ON s.id = t.assigned_staff_id
+ WHERE t.requester_email = ? OR t.user_id = ?
+ ORDER BY t.created_at DESC LIMIT 100"
+);
+$ticketStmt->execute([$email, $userId]);
+$tickets = array_map('api_ticket_summary', $ticketStmt->fetchAll());
+
+api_json([
+ 'customer' => [
+ 'id' => $customer['id'] !== null ? (int) $customer['id'] : null,
+ 'name' => $customer['name'],
+ 'email' => $customer['email'],
+ 'organizationName' => $customer['organization_name'],
+ 'isGuest' => $isGuest,
+ 'createdAt' => $customer['created_at'],
+ ],
+ 'tickets' => $tickets,
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/customers.php b/DeskHub-Webseite/api/staff/v1/customers.php
new file mode 100644
index 00000000..890e20ee
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/customers.php
@@ -0,0 +1,55 @@
+prepare($regSql);
+$regStmt->execute($regParams);
+$registered = array_map(fn(array $row) => [
+ 'id' => (int) $row['id'],
+ 'name' => $row['name'],
+ 'email' => $row['email'],
+ 'organizationName' => $row['organization_name'],
+ 'ticketCount' => (int) $row['ticket_count'],
+ 'isGuest' => false,
+], $regStmt->fetchAll());
+
+$guestSql = "SELECT requester_email AS email, MAX(requester_name) AS name, COUNT(*) AS ticket_count
+ FROM tickets WHERE user_id IS NULL";
+$guestParams = [];
+if ($search !== '') {
+ $guestSql .= ' AND (requester_name LIKE ? OR requester_email LIKE ?)';
+ $guestParams = [$like, $like];
+}
+$guestSql .= ' GROUP BY requester_email ORDER BY requester_email LIMIT 100';
+$guestStmt = $pdo->prepare($guestSql);
+$guestStmt->execute($guestParams);
+$guests = array_map(fn(array $row) => [
+ 'id' => null,
+ 'name' => $row['name'],
+ 'email' => $row['email'],
+ 'organizationName' => null,
+ 'ticketCount' => (int) $row['ticket_count'],
+ 'isGuest' => true,
+], $guestStmt->fetchAll());
+
+$customers = array_merge($registered, $guests);
+usort($customers, fn($a, $b) => strcasecmp($a['name'] ?? '', $b['name'] ?? ''));
+$customers = array_slice($customers, 0, 100);
+
+api_json(['items' => $customers]);
diff --git a/DeskHub-Webseite/api/staff/v1/dashboard.php b/DeskHub-Webseite/api/staff/v1/dashboard.php
new file mode 100644
index 00000000..31d4986c
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/dashboard.php
@@ -0,0 +1,105 @@
+prepare(
+ "SELECT COUNT(*) FROM tickets
+ WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed'){$deptFilter}"
+);
+$stmt->execute([$staff['id'], ...$deptParams]);
+$myOpen = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare(
+ "SELECT COUNT(*) FROM tickets
+ WHERE resolution_due_at IS NOT NULL AND resolution_due_at < ?
+ AND status NOT IN ('resolved','closed'){$deptFilter}"
+);
+$stmt->execute([db_now(), ...$deptParams]);
+$overdue = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare(
+ "SELECT COUNT(*) FROM tickets
+ WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed'){$deptFilter}"
+);
+$stmt->execute($deptParams);
+$unassigned = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'{$deptFilter}");
+$stmt->execute($deptParams);
+$unanswered = (int) $stmt->fetchColumn();
+
+$todayStart = date('Y-m-d 00:00:00');
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolved_at >= ?{$deptFilter}");
+$stmt->execute([$todayStart, ...$deptParams]);
+$resolvedToday = (int) $stmt->fetchColumn();
+
+$periodStart = date('Y-m-d 00:00:00', strtotime('-30 days'));
+$stmt = $pdo->prepare(
+ "SELECT
+ COUNT(*) AS total,
+ SUM(CASE WHEN resolution_due_at IS NULL OR resolved_at <= resolution_due_at THEN 1 ELSE 0 END) AS on_time
+ FROM tickets
+ WHERE resolved_at >= ?{$deptFilter}"
+);
+$stmt->execute([$periodStart, ...$deptParams]);
+$slaRow = $stmt->fetch();
+$slaTotal = (int) ($slaRow['total'] ?? 0);
+$slaPercent = $slaTotal > 0 ? round(((int) $slaRow['on_time'] / $slaTotal) * 100, 1) : null;
+
+// "Priorisiert": dieselbe Auswahl wie in staff/dashboard.php ("mir zugewiesen oder
+// unbearbeitet"), damit die App-Übersicht dieselben Tickets zeigt wie die Web-Oberfläche.
+[$deptFilterAliased, $deptParamsAliased] = dept_filter_sql($departmentIds, 't.department_id');
+$stmt = $pdo->prepare(
+ "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name
+ FROM tickets t
+ JOIN priorities p ON p.id = t.priority_id
+ JOIN departments d ON d.id = t.department_id
+ LEFT JOIN staff s ON s.id = t.assigned_staff_id
+ WHERE t.status NOT IN ('resolved','closed') AND (t.assigned_staff_id = ? OR t.assigned_staff_id IS NULL){$deptFilterAliased}
+ ORDER BY t.resolution_due_at ASC
+ LIMIT 12"
+);
+$stmt->execute([$staff['id'], ...$deptParamsAliased]);
+$recent = array_map('api_ticket_summary', $stmt->fetchAll());
+
+$response = [
+ 'myOpen' => $myOpen,
+ 'overdue' => $overdue,
+ 'unassigned' => $unassigned,
+ 'unanswered' => $unanswered,
+ 'resolvedToday' => $resolvedToday,
+ 'slaPercent' => $slaPercent,
+ 'recent' => $recent,
+ 'departments' => null,
+];
+
+// Voller Überblick über alle Abteilungen nur für uneingeschränkten Zugriff (Admin).
+if ($departmentIds === null) {
+ $stmt = $pdo->prepare(
+ "SELECT d.id, d.name,
+ COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count,
+ COUNT(CASE WHEN t.status NOT IN ('resolved','closed')
+ AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?
+ THEN 1 END) AS overdue_count
+ FROM departments d
+ LEFT JOIN tickets t ON t.department_id = d.id
+ GROUP BY d.id, d.name
+ ORDER BY d.name"
+ );
+ $stmt->execute([db_now()]);
+ $rows = $stmt->fetchAll();
+ $response['departments'] = array_map(fn(array $row) => [
+ 'name' => $row['name'],
+ 'open' => (int) $row['open_count'],
+ 'overdue' => (int) $row['overdue_count'],
+ ], $rows);
+}
+
+api_json($response);
diff --git a/DeskHub-Webseite/api/staff/v1/departments.php b/DeskHub-Webseite/api/staff/v1/departments.php
new file mode 100644
index 00000000..02905dd8
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/departments.php
@@ -0,0 +1,78 @@
+query(
+ 'SELECT d.*, m.name AS manager_name FROM departments d LEFT JOIN staff m ON m.id = d.manager_id ORDER BY d.name'
+ )->fetchAll();
+ $emailOptions = $pdo->query(
+ 'SELECT ea.email, d.name AS department_name FROM email_addresses ea JOIN departments d ON d.id = ea.department_id ORDER BY ea.email'
+ )->fetchAll();
+ $staffOptions = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+
+ // Mitglieder = Hauptabteilung oder zusätzlich zugewiesen
+ $membersByDept = [];
+ $memberRows = $pdo->query(
+ 'SELECT s.id, s.name, s.department_id AS dept_id, 1 AS is_primary FROM staff s WHERE s.department_id IS NOT NULL
+ UNION ALL
+ SELECT s.id, s.name, sd.department_id, 0 FROM staff_department sd JOIN staff s ON s.id = sd.staff_id'
+ )->fetchAll();
+ foreach ($memberRows as $r) {
+ $membersByDept[(int) $r['dept_id']][(int) $r['id']] ??= ['id' => (int) $r['id'], 'name' => $r['name'], 'isPrimary' => (bool) $r['is_primary']];
+ }
+
+ api_json([
+ 'emailOptions' => array_map(fn(array $o) => ['email' => $o['email'], 'departmentName' => $o['department_name']], $emailOptions),
+ 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffOptions),
+ 'slaPlans' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM sla_plans WHERE is_active = 1 ORDER BY name')->fetchAll()),
+ 'items' => array_map(fn(array $d) => [
+ 'id' => (int) $d['id'],
+ 'name' => $d['name'],
+ 'email' => $d['email'],
+ 'isDefault' => (bool) $d['is_default'],
+ 'signature' => $d['signature'],
+ 'isActive' => (bool) ($d['is_active'] ?? 1),
+ 'slaPlanId' => !empty($d['sla_plan_id']) ? (int) $d['sla_plan_id'] : null,
+ 'managerId' => !empty($d['manager_id']) ? (int) $d['manager_id'] : null,
+ 'managerName' => $d['manager_name'] ?? null,
+ 'notes' => $d['notes'] ?? null,
+ 'members' => array_values($membersByDept[(int) $d['id']] ?? []),
+ 'createdAt' => $d['created_at'] ?? null,
+ 'updatedAt' => $d['updated_at'] ?? null,
+ ], $departments),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$id = (int) ($body['id'] ?? 0);
+
+try {
+ if ($action === 'save') {
+ $in = [
+ 'name' => $body['name'] ?? '', 'email' => $body['email'] ?? '', 'is_default' => !empty($body['isDefault']),
+ 'manager_id' => $body['managerId'] ?? null, 'notes' => $body['notes'] ?? '', 'signature' => $body['signature'] ?? '',
+ ];
+ // Fehlende Schlüssel (ältere App-Version) lassen Status und SLA-Plan unverändert
+ if (array_key_exists('isActive', $body)) $in['is_active'] = !empty($body['isActive']);
+ if (array_key_exists('slaPlanId', $body)) $in['sla_plan_id'] = $body['slaPlanId'];
+ $newId = department_save($pdo, $id, $in);
+ audit_log($staff['id'], ($id === 0 ? 'Abteilung erstellt: ' : 'Abteilung aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
+ api_json(['id' => $newId]);
+ }
+ if ($action === 'delete') {
+ $deleted = department_delete($pdo, $id);
+ audit_log($staff['id'], "Abteilung gelöscht: {$deleted} (Mobile App)");
+ api_json(['ok' => true]);
+ }
+} catch (AdminModelException $e) {
+ api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/email_addresses.php b/DeskHub-Webseite/api/staff/v1/email_addresses.php
new file mode 100644
index 00000000..54a7e267
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/email_addresses.php
@@ -0,0 +1,108 @@
+query(
+ 'SELECT ea.*, d.name AS department_name, p.name AS priority_name, h.name AS topic_name
+ FROM email_addresses ea
+ JOIN departments d ON d.id = ea.department_id
+ LEFT JOIN priorities p ON p.id = ea.priority_id
+ LEFT JOIN help_topics h ON h.id = ea.help_topic_id
+ ORDER BY d.name, ea.email'
+ )->fetchAll();
+ $departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN)));
+ $priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+ $topics = help_topic_options($pdo, false, false);
+
+ $imapByAddress = [];
+ foreach ($pdo->query('SELECT * FROM imap_accounts WHERE email_address_id IS NOT NULL ORDER BY id')->fetchAll() as $row) {
+ $imapByAddress[(int) $row['email_address_id']] ??= $row;
+ }
+
+ api_json([
+ 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
+ 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
+ 'helpTopics' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['path']], $topics),
+ 'items' => array_map(function (array $a) use ($imapByAddress) {
+ $imap = $imapByAddress[(int) $a['id']] ?? null;
+ return [
+ 'id' => (int) $a['id'],
+ 'email' => $a['email'],
+ 'label' => $a['label'],
+ 'departmentId' => (int) $a['department_id'],
+ 'departmentName' => $a['department_name'],
+ 'priorityId' => $a['priority_id'] !== null ? (int) $a['priority_id'] : null,
+ 'priorityName' => $a['priority_name'],
+ 'helpTopicId' => $a['help_topic_id'] !== null ? (int) $a['help_topic_id'] : null,
+ 'helpTopicName' => $a['topic_name'],
+ 'isDefault' => (bool) $a['is_default'],
+ 'isActive' => (bool) ($a['is_active'] ?? 1),
+ 'notes' => $a['notes'] ?? null,
+ 'updatedAt' => $a['updated_at'] ?? null,
+ 'imapEnabled' => $imap !== null && (bool) $imap['is_active'],
+ 'imapHost' => $imap['host'] ?? '',
+ 'imapPort' => (int) ($imap['port'] ?? 993),
+ 'imapEncryption' => $imap['encryption'] ?? 'ssl',
+ 'imapUsername' => $imap['username'] ?? '',
+ 'imapFolder' => $imap['folder'] ?? 'INBOX',
+ 'imapAfterFetch' => $imap['after_fetch'] ?? 'seen',
+ 'imapArchiveFolder' => $imap['archive_folder'] ?? 'Processed',
+ 'imapSpamFolder' => $imap['spam_folder'] ?? 'Junk',
+ 'imapHasPassword' => $imap !== null && ($imap['password_enc'] ?? '') !== '',
+ 'imapLastRunAt' => $imap['last_run_at'] ?? null,
+ 'smtpEnabled' => (bool) ($a['smtp_enabled'] ?? 0),
+ 'smtpHost' => $a['smtp_host'] ?? '',
+ 'smtpPort' => (int) ($a['smtp_port'] ?? 587),
+ 'smtpEncryption' => $a['smtp_encryption'] ?? 'tls',
+ 'smtpUsername' => $a['smtp_username'] ?? '',
+ 'smtpHasPassword' => ($a['smtp_password_enc'] ?? '') !== '',
+ ];
+ }, $addresses),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$id = (int) ($body['id'] ?? 0);
+
+try {
+ if ($action === 'save') {
+ $b = fn(string $k, $d = null) => $body[$k] ?? $d;
+ $in = [
+ 'email' => $b('email', ''), 'label' => $b('label', ''), 'department_id' => $b('departmentId', 0),
+ 'priority_id' => $b('priorityId'), 'help_topic_id' => $b('helpTopicId'), 'is_default' => !empty($body['isDefault']),
+ 'notes' => $b('notes', ''),
+ 'imap_enabled' => !empty($body['imapEnabled']), 'imap_host' => $b('imapHost', ''), 'imap_port' => $b('imapPort', 993),
+ 'imap_encryption' => $b('imapEncryption', 'ssl'), 'imap_username' => $b('imapUsername', ''), 'imap_password' => $b('imapPassword', ''),
+ 'imap_folder' => $b('imapFolder', ''), 'imap_after_fetch' => $b('imapAfterFetch', 'seen'),
+ 'imap_archive_folder' => $b('imapArchiveFolder', ''), 'imap_spam_folder' => $b('imapSpamFolder', ''),
+ 'smtp_enabled' => !empty($body['smtpEnabled']), 'smtp_host' => $b('smtpHost', ''), 'smtp_port' => $b('smtpPort', 587),
+ 'smtp_encryption' => $b('smtpEncryption', 'tls'), 'smtp_username' => $b('smtpUsername', ''), 'smtp_password' => $b('smtpPassword', ''),
+ ];
+ if (array_key_exists('isActive', $body)) $in['is_active'] = !empty($body['isActive']);
+ $newId = email_address_save($pdo, $id, $in);
+ audit_log($staff['id'], ($id === 0 ? 'E-Mail-Adresse erstellt: ' : 'E-Mail-Adresse aktualisiert: ') . strtolower(trim((string) ($body['email'] ?? ''))) . ' (Mobile App)');
+ api_json(['id' => $newId]);
+ }
+
+ if ($action === 'delete') {
+ $deleted = email_address_delete($pdo, $id);
+ audit_log($staff['id'], "E-Mail-Adresse gelöscht: {$deleted} (Mobile App)");
+ api_json(['ok' => true]);
+ }
+
+ // Tests: Test-Mail an die eigene Adresse, IMAP-Verbindung, SMTP-Erreichbarkeit
+ if ($action === 'sendTest') api_json(['ok' => true, 'message' => email_address_send_test($pdo, $id, (string) $staff['email'])]);
+ if ($action === 'testImap') api_json(['ok' => true, 'message' => email_address_test_imap($pdo, $id)]);
+ if ($action === 'testSmtp') api_json(['ok' => true, 'message' => email_address_test_smtp($pdo, $id)]);
+} catch (EmailAddressException $e) {
+ api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/email_templates.php b/DeskHub-Webseite/api/staff/v1/email_templates.php
new file mode 100644
index 00000000..7a9a58e0
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/email_templates.php
@@ -0,0 +1,84 @@
+ [
+ 'label' => 'Ticket erstellt (an Kunde)',
+ 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'department_name', 'ticket_url', 'lookup_url', 'site_name'],
+ 'defaultSubject' => 'Ihr Ticket #{ticket_number} wurde erstellt',
+ 'defaultBody' => email_ticket_created_default_body(),
+ ],
+ 'new_reply' => [
+ 'label' => 'Neue Antwort (an Kunde)',
+ 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'message', 'ticket_url'],
+ 'defaultSubject' => '[#{ticket_number}] {subject}',
+ 'defaultBody' => email_new_reply_default_body(),
+ ],
+ 'new_ticket_department' => [
+ 'label' => 'Neues Ticket (an Abteilung)',
+ 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'customer_email', 'department_name', 'ticket_url'],
+ 'defaultSubject' => 'Neues Ticket [#{ticket_number}] {subject}',
+ 'defaultBody' => email_new_ticket_department_default_body(),
+ ],
+ 'ticket_overdue' => [
+ 'label' => 'Ticket überfällig (an Admins)',
+ 'placeholders' => ['ticket_number', 'subject', 'department_name', 'ticket_url'],
+ 'defaultSubject' => 'SLA überschritten: Ticket #{ticket_number}',
+ 'defaultBody' => email_ticket_overdue_default_body(),
+ ],
+ ];
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'GET') {
+ $items = [];
+ foreach (api_email_templates_definitions() as $key => $tpl) {
+ $customSubject = get_setting("email_template_{$key}_subject");
+ $customBody = get_setting("email_template_{$key}_body");
+ $items[] = [
+ 'key' => $key,
+ 'label' => $tpl['label'],
+ 'placeholders' => $tpl['placeholders'],
+ 'subject' => $customSubject ?: $tpl['defaultSubject'],
+ 'body' => $customBody ?: $tpl['defaultBody'],
+ 'isCustomized' => $customSubject !== null,
+ ];
+ }
+ api_json(['items' => $items]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$definitions = api_email_templates_definitions();
+$key = (string) ($body['key'] ?? '');
+if (!isset($definitions[$key])) {
+ api_error(422, 'INVALID_TEMPLATE', 'Unbekannte Vorlage.');
+}
+
+if ($action === 'save') {
+ $subject = trim((string) ($body['subject'] ?? ''));
+ $bodyText = sanitize_staff_html(trim((string) ($body['body'] ?? '')));
+ if ($subject === '') api_error(422, 'SUBJECT_REQUIRED', 'Bitte einen Betreff angeben.');
+ if (trim(strip_tags($bodyText)) === '') api_error(422, 'BODY_REQUIRED', 'Bitte einen Text angeben.');
+
+ set_setting("email_template_{$key}_subject", $subject);
+ set_setting("email_template_{$key}_body", $bodyText);
+ audit_log($staff['id'], "E-Mail-Vorlage geändert: {$definitions[$key]['label']} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+if ($action === 'reset') {
+ set_setting("email_template_{$key}_subject", null);
+ set_setting("email_template_{$key}_body", null);
+ audit_log($staff['id'], "E-Mail-Vorlage zurückgesetzt: {$definitions[$key]['label']} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/escalation_rules.php b/DeskHub-Webseite/api/staff/v1/escalation_rules.php
new file mode 100644
index 00000000..7a9d822b
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/escalation_rules.php
@@ -0,0 +1,140 @@
+query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+ $staffList = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+ $depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
+
+ try {
+ $rules = $pdo->query(
+ 'SELECT er.*,
+ p.name AS prio_name,
+ s.name AS staff_name,
+ d.name AS dept_name,
+ cp.name AS cond_prio_name,
+ (SELECT COUNT(*) FROM escalation_log el WHERE el.rule_id = er.id) AS applied_count
+ FROM escalation_rules er
+ LEFT JOIN priorities p ON p.id = er.action_set_priority_id
+ LEFT JOIN staff s ON s.id = er.action_assign_staff_id
+ LEFT JOIN departments d ON d.id = er.condition_dept_id
+ LEFT JOIN priorities cp ON cp.id = er.condition_priority_id
+ ORDER BY er.sort_order, er.id'
+ )->fetchAll();
+ } catch (Throwable $e) {
+ $rules = [];
+ }
+
+ api_json([
+ 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
+ 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffList),
+ 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $depts),
+ 'rules' => array_map(fn(array $r) => [
+ 'id' => (int) $r['id'],
+ 'name' => $r['name'],
+ 'isActive' => (bool) $r['is_active'],
+ 'triggerType' => $r['trigger_type'],
+ 'triggerHours' => (int) $r['trigger_hours'],
+ 'conditionStatus' => $r['condition_status'],
+ 'conditionPriorityId' => $r['condition_priority_id'] !== null ? (int) $r['condition_priority_id'] : null,
+ 'conditionPriorityName' => $r['cond_prio_name'],
+ 'conditionDeptId' => $r['condition_dept_id'] !== null ? (int) $r['condition_dept_id'] : null,
+ 'conditionDeptName' => $r['dept_name'],
+ 'actionSetPriorityId' => $r['action_set_priority_id'] !== null ? (int) $r['action_set_priority_id'] : null,
+ 'actionSetPriorityName' => $r['prio_name'],
+ 'actionAssignStaffId' => $r['action_assign_staff_id'] !== null ? (int) $r['action_assign_staff_id'] : null,
+ 'actionAssignStaffName' => $r['staff_name'],
+ 'actionSetStatus' => $r['action_set_status'],
+ 'actionNotifyAdmin' => (bool) $r['action_notify_admin'],
+ 'actionNotifyStaff' => (bool) $r['action_notify_staff'],
+ 'sortOrder' => (int) $r['sort_order'],
+ 'appliedCount' => (int) $r['applied_count'],
+ ], $rules),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'toggle') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('UPDATE escalation_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
+ api_json(['ok' => true]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('DELETE FROM escalation_rules WHERE id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM escalation_log WHERE rule_id = ?')->execute([$id]);
+ audit_log($staff['id'], "Eskalationsregel gelöscht: #$id (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+if ($action === 'save') {
+ $id = (int) ($body['id'] ?? 0);
+ $name = trim((string) ($body['name'] ?? ''));
+ $isActive = !empty($body['isActive']) ? 1 : 0;
+ $triggerType = (string) ($body['triggerType'] ?? 'no_reply');
+ $triggerHours = max(1, (int) ($body['triggerHours'] ?? 24));
+ $condStatus = trim((string) ($body['conditionStatus'] ?? ''));
+ $condPrioId = isset($body['conditionPriorityId']) && $body['conditionPriorityId'] !== null ? (int) $body['conditionPriorityId'] : null;
+ $condDeptId = isset($body['conditionDeptId']) && $body['conditionDeptId'] !== null ? (int) $body['conditionDeptId'] : null;
+ $actPrioId = isset($body['actionSetPriorityId']) && $body['actionSetPriorityId'] !== null ? (int) $body['actionSetPriorityId'] : null;
+ $actStaffId = isset($body['actionAssignStaffId']) && $body['actionAssignStaffId'] !== null ? (int) $body['actionAssignStaffId'] : null;
+ $actStatus = (string) ($body['actionSetStatus'] ?? '');
+ $actNotAdmin = !empty($body['actionNotifyAdmin']) ? 1 : 0;
+ $actNotStaff = !empty($body['actionNotifyStaff']) ? 1 : 0;
+ $sortOrder = (int) ($body['sortOrder'] ?? 0);
+
+ if ($name === '') {
+ api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
+ }
+ if (!in_array($triggerType, ESCALATION_TRIGGER_TYPES, true)) {
+ api_error(422, 'INVALID_TRIGGER', 'Ungültiger Trigger.');
+ }
+ if ($actStatus !== '' && !in_array($actStatus, ESCALATION_SET_STATUSES, true)) {
+ api_error(422, 'INVALID_STATUS', 'Ungültiger Zielstatus.');
+ }
+
+ $data = [$name, $isActive, $triggerType, $triggerHours, $condStatus ?: null,
+ $condPrioId, $condDeptId, $actPrioId, $actStaffId,
+ $actStatus ?: null, $actNotAdmin, $actNotStaff, $sortOrder];
+
+ if ($id) {
+ $existsStmt = $pdo->prepare('SELECT COUNT(*) FROM escalation_rules WHERE id = ?');
+ $existsStmt->execute([$id]);
+ if (!(bool) $existsStmt->fetchColumn()) {
+ api_error(404, 'RULE_NOT_FOUND', 'Die Regel wurde nicht gefunden.');
+ }
+ $pdo->prepare(
+ 'UPDATE escalation_rules SET name=?,is_active=?,trigger_type=?,trigger_hours=?,
+ condition_status=?,condition_priority_id=?,condition_dept_id=?,
+ action_set_priority_id=?,action_assign_staff_id=?,action_set_status=?,
+ action_notify_admin=?,action_notify_staff=?,sort_order=? WHERE id=?'
+ )->execute(array_merge($data, [$id]));
+ audit_log($staff['id'], "Eskalationsregel geändert: $name (Mobile App)");
+ $newId = $id;
+ } else {
+ $pdo->prepare(
+ 'INSERT INTO escalation_rules (name,is_active,trigger_type,trigger_hours,
+ condition_status,condition_priority_id,condition_dept_id,
+ action_set_priority_id,action_assign_staff_id,action_set_status,
+ action_notify_admin,action_notify_staff,sort_order,created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
+ )->execute(array_merge($data, [db_now()]));
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "Eskalationsregel erstellt: $name (Mobile App)");
+ }
+ api_json(['id' => $newId]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/help_topics.php b/DeskHub-Webseite/api/staff/v1/help_topics.php
new file mode 100644
index 00000000..0df682a3
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/help_topics.php
@@ -0,0 +1,80 @@
+query(
+ 'SELECT h.*, d.name AS department_name, p.name AS priority_name, p.color AS priority_color
+ FROM help_topics h
+ JOIN departments d ON d.id = h.department_id
+ JOIN priorities p ON p.id = h.priority_id
+ ORDER BY h.sort_order, h.name'
+ )->fetchAll();
+ $departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM help_topics')->fetchAll(PDO::FETCH_COLUMN)));
+ $priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+ $names = array_column($topics, 'name', 'id');
+ $parents = array_column($topics, 'parent_id', 'id');
+ $pathOf = function (int $id) use ($names, $parents): string {
+ $parts = []; $seen = [];
+ while ($id && isset($names[$id]) && !isset($seen[$id])) { $seen[$id] = true; array_unshift($parts, $names[$id]); $id = (int) ($parents[$id] ?? 0); }
+ return implode(' / ', $parts);
+ };
+
+ api_json([
+ 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
+ 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
+ 'slaPlans' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM sla_plans WHERE is_active = 1 ORDER BY name')->fetchAll()),
+ 'items' => array_map(fn(array $t) => [
+ 'id' => (int) $t['id'],
+ 'name' => $t['name'],
+ 'departmentId' => (int) $t['department_id'],
+ 'departmentName' => $t['department_name'],
+ 'priorityId' => (int) $t['priority_id'],
+ 'priorityName' => $t['priority_name'],
+ 'priorityColor' => $t['priority_color'],
+ 'isActive' => (bool) $t['is_active'],
+ 'isPublic' => (bool) ($t['is_public'] ?? 1),
+ 'slaPlanId' => !empty($t['sla_plan_id']) ? (int) $t['sla_plan_id'] : null,
+ 'parentId' => !empty($t['parent_id']) ? (int) $t['parent_id'] : null,
+ 'path' => $pathOf((int) $t['id']),
+ 'notes' => $t['notes'] ?? null,
+ 'sortOrder' => (int) $t['sort_order'],
+ 'createdAt' => $t['created_at'] ?? null,
+ 'updatedAt' => $t['updated_at'] ?? null,
+ ], $topics),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$id = (int) ($body['id'] ?? 0);
+
+try {
+ if ($action === 'save') {
+ $in = [
+ 'name' => $body['name'] ?? '', 'department_id' => $body['departmentId'] ?? 0, 'priority_id' => $body['priorityId'] ?? 0,
+ 'sort_order' => $body['sortOrder'] ?? 0,
+ ];
+ // Fehlende Schlüssel (ältere App-Version) lassen den bisherigen Wert unverändert
+ foreach (['isActive' => 'is_active', 'isPublic' => 'is_public', 'parentId' => 'parent_id', 'slaPlanId' => 'sla_plan_id', 'notes' => 'notes'] as $camel => $snake) {
+ if (array_key_exists($camel, $body)) $in[$snake] = $body[$camel];
+ }
+ $newId = help_topic_save($pdo, $id, $in);
+ audit_log($staff['id'], ($id === 0 ? 'Hilfethema erstellt: ' : 'Hilfethema aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
+ api_json(['id' => $newId]);
+ }
+ if ($action === 'delete') {
+ $deleted = help_topic_delete($pdo, $id);
+ audit_log($staff['id'], "Hilfethema gelöscht: {$deleted} (Mobile App)");
+ api_json(['ok' => true]);
+ }
+} catch (AdminModelException $e) {
+ api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/kb.php b/DeskHub-Webseite/api/staff/v1/kb.php
new file mode 100644
index 00000000..07c78353
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/kb.php
@@ -0,0 +1,123 @@
+query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
+ $articles = $pdo->query(
+ 'SELECT a.*, c.name AS category_name FROM kb_articles a JOIN kb_categories c ON c.id=a.category_id ORDER BY a.title'
+ )->fetchAll();
+
+ api_json([
+ 'categories' => array_map(fn(array $c) => [
+ 'id' => (int) $c['id'],
+ 'name' => $c['name'],
+ ], $categories),
+ 'articles' => array_map(fn(array $a) => [
+ 'id' => (int) $a['id'],
+ 'categoryId' => (int) $a['category_id'],
+ 'categoryName' => $a['category_name'],
+ 'title' => $a['title'],
+ 'body' => $a['body'],
+ 'isPublished' => (bool) $a['is_published'],
+ 'views' => (int) $a['views'],
+ ], $articles),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'create_category') {
+ $name = trim((string) ($body['name'] ?? ''));
+ if ($name === '') {
+ api_error(422, 'NAME_REQUIRED', 'Bitte einen Kategorienamen angeben.');
+ }
+ $slug = slugify($name);
+ $pdo->prepare('INSERT INTO kb_categories (name, slug, sort_order) VALUES (?,?,0)')->execute([$name, $slug]);
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "KB-Kategorie erstellt: {$name} (Mobile App)");
+ api_json(['id' => $newId]);
+}
+
+if ($action === 'delete_category') {
+ $id = (int) ($body['id'] ?? 0);
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE category_id = ?');
+ $stmt->execute([$id]);
+ if ((int) $stmt->fetchColumn() > 0) {
+ api_error(422, 'CATEGORY_NOT_EMPTY', 'Diese Kategorie enthält noch Artikel und kann nicht gelöscht werden.');
+ }
+ $nameStmt = $pdo->prepare('SELECT name FROM kb_categories WHERE id = ?');
+ $nameStmt->execute([$id]);
+ $catName = $nameStmt->fetchColumn();
+ $pdo->prepare('DELETE FROM kb_categories WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "KB-Kategorie gelöscht: {$catName} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+if ($action === 'save_article') {
+ $id = (int) ($body['id'] ?? 0);
+ $title = trim((string) ($body['title'] ?? ''));
+ $categoryId = (int) ($body['categoryId'] ?? 0);
+ $articleBody = sanitize_staff_html((string) ($body['body'] ?? ''));
+ $isPublished = !empty($body['isPublished']) ? 1 : 0;
+
+ if ($title === '') {
+ api_error(422, 'TITLE_REQUIRED', 'Bitte einen Titel angeben.');
+ }
+ if (trim(strip_tags($articleBody)) === '') {
+ api_error(422, 'BODY_REQUIRED', 'Bitte einen Inhalt angeben.');
+ }
+ $catCheck = $pdo->prepare('SELECT COUNT(*) FROM kb_categories WHERE id = ?');
+ $catCheck->execute([$categoryId]);
+ if (!(bool) $catCheck->fetchColumn()) {
+ api_error(422, 'INVALID_CATEGORY', 'Bitte eine gültige Kategorie wählen.');
+ }
+
+ if ($id === 0) {
+ $slug = slugify($title);
+ $base = $slug;
+ $i = 2;
+ $check = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE slug = ?');
+ while (true) {
+ $check->execute([$slug]);
+ if ((int) $check->fetchColumn() === 0) break;
+ $slug = $base . '-' . $i++;
+ }
+ $pdo->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
+ ->execute([$categoryId, $title, $slug, $articleBody, $isPublished, db_now(), db_now()]);
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "KB-Artikel erstellt: {$title} (Mobile App)");
+ } else {
+ $existsStmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE id = ?');
+ $existsStmt->execute([$id]);
+ if (!(bool) $existsStmt->fetchColumn()) {
+ api_error(404, 'ARTICLE_NOT_FOUND', 'Der Artikel wurde nicht gefunden.');
+ }
+ $pdo->prepare('UPDATE kb_articles SET category_id=?, title=?, body=?, is_published=?, updated_at=? WHERE id=?')
+ ->execute([$categoryId, $title, $articleBody, $isPublished, db_now(), $id]);
+ $newId = $id;
+ audit_log($staff['id'], "KB-Artikel aktualisiert: {$title} (Mobile App)");
+ }
+ api_json(['id' => $newId]);
+}
+
+if ($action === 'delete_article') {
+ $id = (int) ($body['id'] ?? 0);
+ $titleStmt = $pdo->prepare('SELECT title FROM kb_articles WHERE id = ?');
+ $titleStmt->execute([$id]);
+ $artTitle = $titleStmt->fetchColumn();
+ if ($artTitle === false) {
+ api_error(404, 'ARTICLE_NOT_FOUND', 'Der Artikel wurde nicht gefunden.');
+ }
+ $pdo->prepare('DELETE FROM kb_articles WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "KB-Artikel gelöscht: {$artTitle} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/live_chat.php b/DeskHub-Webseite/api/staff/v1/live_chat.php
new file mode 100644
index 00000000..a4221683
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/live_chat.php
@@ -0,0 +1,185 @@
+prepare(
+ 'SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id = t.department_id
+ WHERE t.ticket_number = ? AND t.source = \'chat\''
+ . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')
+ );
+ $stmt->execute(array_merge([$number], $deptFilterParams));
+ return $stmt->fetch() ?: null;
+}
+
+function lc_api_messages(PDO $pdo, int $ticketId, int $since): array
+{
+ $stmt = $pdo->prepare(
+ 'SELECT tt.id, tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staffName
+ FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
+ WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 AND tt.id > ?
+ ORDER BY tt.created_at ASC, tt.id ASC'
+ );
+ $stmt->execute([$ticketId, $since]);
+ $attStmt = $pdo->prepare('SELECT id, original_name AS name, filesize AS size FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
+ return array_map(function (array $m) use ($attStmt) {
+ $attStmt->execute([$m['id']]);
+ return [
+ 'id' => (int) $m['id'],
+ 'authorType' => $m['authorType'],
+ 'authorName' => $m['authorType'] === 'staff' ? ($m['staffName'] ?: 'Support') : null,
+ 'message' => api_plain_text($m['message']),
+ 'createdAt' => $m['createdAt'],
+ 'attachments' => array_map(fn(array $a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'size' => (int) $a['size']], $attStmt->fetchAll()),
+ ];
+ }, $stmt->fetchAll());
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'GET') {
+ $action = $_GET['action'] ?? '';
+
+ if ($action === 'list') {
+ $listStmt = $pdo->prepare(
+ "SELECT t.ticket_number AS number, t.requester_name AS name, t.updated_at AS updatedAt,
+ (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS lastAuthor,
+ (SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS lastMessage
+ FROM tickets t
+ WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
+ ORDER BY t.updated_at DESC LIMIT 100"
+ );
+ $listStmt->execute($deptFilterParams);
+ api_json(['items' => array_map(fn(array $c) => [
+ 'number' => $c['number'],
+ 'name' => $c['name'],
+ 'preview' => mb_substr(strip_tags((string) $c['lastMessage']), 0, 60),
+ 'needsReply' => $c['lastAuthor'] === 'customer',
+ 'updatedAt' => $c['updatedAt'],
+ ], $listStmt->fetchAll())]);
+ }
+
+ if ($action === 'messages') {
+ $number = trim((string) ($_GET['number'] ?? ''));
+ if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
+ $ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams);
+ if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.');
+
+ $since = (int) ($_GET['since'] ?? 0);
+ // Sperre übernehmen/auffrischen bzw. feststellen, dass gerade jemand anders aktiv
+ // dran ist – wie im Web-Postfach (siehe staff/live_chat.php). Lesen bleibt für
+ // alle möglich, nur das Schreiben wird unten (Aktion "send") verwehrt.
+ $lockedByOtherName = null;
+ if ($ticket['status'] !== 'closed') {
+ $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
+ if (!$lockedByOtherName) {
+ live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo);
+ }
+ }
+ $customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time();
+
+ api_json([
+ 'status' => $ticket['status'],
+ 'requesterName' => $ticket['requester_name'],
+ 'requesterEmail' => $ticket['requester_email'],
+ 'departmentName' => $ticket['department_name'],
+ 'lockedByOtherName' => $lockedByOtherName,
+ 'customerTyping' => $customerTyping,
+ 'messages' => lc_api_messages($pdo, (int) $ticket['id'], $since),
+ ]);
+ }
+
+ api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+}
+
+// ── POST: send / end / typing ───────────────────────────────────────────────
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$number = trim((string) ($body['number'] ?? ''));
+if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
+$ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams);
+if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.');
+
+if ($action === 'typing') {
+ if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
+ $pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?')
+ ->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
+ }
+ api_json(['ok' => true]);
+}
+
+if ($action === 'send') {
+ api_require_permission($staff, 'reply_ticket', (int) $ticket['department_id']);
+ if ($ticket['status'] === 'closed') api_error(409, 'CHAT_CLOSED', 'Dieser Chat wurde bereits beendet.');
+ if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
+ api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
+ }
+ $message = sanitize_staff_html(trim((string) ($body['message'] ?? '')));
+ if (trim(strip_tags($message)) === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.');
+ if (strlen($message) > 100000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
+
+ $now = db_now();
+ $pdo->prepare(
+ 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
+ )->execute([$ticket['id'], 'staff', $staff['id'], $message, $now]);
+ $threadId = (int) $pdo->lastInsertId();
+
+ $autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === '');
+ $updateFirstResponse = $ticket['first_responded_at'] === null;
+ $sql = 'UPDATE tickets SET status = ?, updated_at = ?'
+ . ($updateFirstResponse ? ', first_responded_at = ?' : '')
+ . ($autoAssign ? ', assigned_staff_id = ?' : '')
+ . ' WHERE id = ?';
+ $args = ['answered', $now];
+ if ($updateFirstResponse) $args[] = $now;
+ if ($autoAssign) $args[] = $staff['id'];
+ $args[] = $ticket['id'];
+ $pdo->prepare($sql)->execute($args);
+ if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)');
+
+ save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
+ log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat-Antwort gesendet (Mobile App)');
+ finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
+
+ api_json(['id' => $threadId, 'createdAt' => $now, 'status' => 'answered']);
+}
+
+if ($action === 'end') {
+ api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
+ if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
+ api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
+ }
+ $now = db_now();
+ $pdo->prepare(
+ "UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
+ )->execute([$now, $now, $ticket['id']]);
+ log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)');
+ finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
+
+ if ($ticket['requester_email']) {
+ try {
+ notify_customer_chat_transcript($ticket, lc_api_messages($pdo, (int) $ticket['id'], 0));
+ } catch (Throwable $e) {
+ error_log('live_chat.php (Mobile App) transcript mail: ' . $e->getMessage());
+ }
+ }
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/login.php b/DeskHub-Webseite/api/staff/v1/login.php
new file mode 100644
index 00000000..e5acc006
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/login.php
@@ -0,0 +1,44 @@
+prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
+$stmt->execute([$email]);
+$staff = $stmt->fetch();
+if (!$staff || !password_verify($password, $staff['password_hash'])) {
+ api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.');
+}
+if (!empty($staff['totp_enabled'])) {
+ if ($totpCode === '') {
+ api_json(['requiresTotp' => true], 202);
+ }
+ if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) {
+ api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.');
+ }
+}
+
+rate_limit_reset('mobile_staff_login_email', $email);
+rate_limit_reset('mobile_staff_login_ip', client_ip());
+$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
+audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
+api_json([
+ 'token' => $issued['token'],
+ 'expiresAt' => $issued['expires_at'],
+ 'staff' => api_staff_payload($staff),
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/logout.php b/DeskHub-Webseite/api/staff/v1/logout.php
new file mode 100644
index 00000000..cbdf6bfe
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/logout.php
@@ -0,0 +1,10 @@
+prepare('UPDATE mobile_api_tokens SET revoked_at = ? WHERE id = ?')
+ ->execute([db_now(), $staff['mobile_token_id']]);
+audit_log((int) $staff['id'], 'Mobile App: Abmeldung');
+api_json(['ok' => true]);
diff --git a/DeskHub-Webseite/api/staff/v1/me.php b/DeskHub-Webseite/api/staff/v1/me.php
new file mode 100644
index 00000000..b2a2442f
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/me.php
@@ -0,0 +1,7 @@
+ api_staff_payload($staff)]);
diff --git a/DeskHub-Webseite/api/staff/v1/metadata.php b/DeskHub-Webseite/api/staff/v1/metadata.php
new file mode 100644
index 00000000..bd4efa60
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/metadata.php
@@ -0,0 +1,45 @@
+prepare('SELECT id, name, is_active FROM departments WHERE 1=1' . $filter . ' ORDER BY name');
+$stmt->execute($params);
+$departments = array_map(
+ fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name'] . ($row['is_active'] ? '' : ' (deaktiviert)')],
+ $stmt->fetchAll()
+);
+$priorities = array_map(
+ fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name'], 'color' => $row['color']],
+ $pdo->query('SELECT id, name, color FROM priorities ORDER BY sort_order')->fetchAll()
+);
+$agents = array_map(
+ fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name']],
+ $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll()
+);
+$statuses = array_map(function (string $value): array {
+ [$label, $color] = status_label($value);
+ return ['value' => $value, 'label' => $label, 'color' => $color];
+}, MOBILE_API_STATUSES);
+
+$tags = array_map(
+ fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name']],
+ $pdo->query('SELECT id, name FROM tags ORDER BY name')->fetchAll()
+);
+
+$health = has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [];
+
+api_json([
+ 'health' => $health,
+ 'staff' => api_staff_payload($staff),
+ 'departments' => $departments,
+ 'priorities' => $priorities,
+ 'agents' => $agents,
+ 'statuses' => $statuses,
+ 'tags' => $tags,
+ 'liveChatEnabled' => get_setting('live_chat_enabled', '0') === '1',
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/organizations.php b/DeskHub-Webseite/api/staff/v1/organizations.php
new file mode 100644
index 00000000..337e04fc
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/organizations.php
@@ -0,0 +1,54 @@
+query(
+ 'SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id) AS member_count
+ FROM organizations o ORDER BY o.name'
+ )->fetchAll();
+ api_json(['items' => array_map(fn(array $o) => [
+ 'id' => (int) $o['id'],
+ 'name' => $o['name'],
+ 'notes' => $o['notes'],
+ 'memberCount' => (int) $o['member_count'],
+ ], $organizations)]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'save') {
+ $id = (int) ($body['id'] ?? 0);
+ $name = trim((string) ($body['name'] ?? ''));
+ $notes = trim((string) ($body['notes'] ?? ''));
+
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
+
+ if ($id === 0) {
+ $pdo->prepare('INSERT INTO organizations (name, notes, created_at) VALUES (?,?,?)')
+ ->execute([$name, $notes, db_now()]);
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "Organisation erstellt: {$name} (Mobile App)");
+ } else {
+ $pdo->prepare('UPDATE organizations SET name=?, notes=? WHERE id=?')->execute([$name, $notes, $id]);
+ $newId = $id;
+ audit_log($staff['id'], "Organisation aktualisiert: {$name} (Mobile App)");
+ }
+ api_json(['id' => $newId]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('UPDATE users SET organization_id = NULL WHERE organization_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM organizations WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "Organisation gelöscht: #{$id} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/plugin.php b/DeskHub-Webseite/api/staff/v1/plugin.php
new file mode 100644
index 00000000..a9fc9078
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/plugin.php
@@ -0,0 +1,18 @@
+ ($entry['fn'])($staff, $request), null);
+if (!is_array($result)) api_error(500, 'PLUGIN_ERROR', 'Das Plugin hat keine gültige Antwort geliefert.');
+api_json($result);
diff --git a/DeskHub-Webseite/api/staff/v1/priorities.php b/DeskHub-Webseite/api/staff/v1/priorities.php
new file mode 100644
index 00000000..ec3be9ff
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/priorities.php
@@ -0,0 +1,78 @@
+query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
+ api_json(['items' => array_map(fn(array $p) => [
+ 'id' => (int) $p['id'],
+ 'name' => $p['name'],
+ 'color' => $p['color'],
+ 'responseHours' => (int) $p['response_hours'],
+ 'resolutionHours' => (int) $p['resolution_hours'],
+ 'sortOrder' => (int) $p['sort_order'],
+ 'notes' => $p['notes'] ?? null,
+ 'createdAt' => $p['created_at'] ?? null,
+ 'updatedAt' => $p['updated_at'] ?? null,
+ ], $priorities)]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'save') {
+ $id = (int) ($body['id'] ?? 0);
+ $name = trim((string) ($body['name'] ?? ''));
+ $color = trim((string) ($body['color'] ?? '#888888'));
+ $response = (int) ($body['responseHours'] ?? 0);
+ $resolution = (int) ($body['resolutionHours'] ?? 0);
+ $sort = (int) ($body['sortOrder'] ?? 0);
+
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
+ if ($response <= 0 || $resolution <= 0) api_error(422, 'INVALID_HOURS', 'Bitte gültige SLA-Stunden angeben (größer als 0).');
+ if ($response > $resolution) api_error(422, 'INVALID_HOURS', 'Die Reaktionszeit darf nicht länger als die Lösungszeit sein.');
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE LOWER(name) = LOWER(?) AND id <> ?');
+ $dup->execute([$name, $id]);
+ if ((int) $dup->fetchColumn() > 0) api_error(422, 'NAME_TAKEN', 'Eine Priorität mit diesem Namen existiert bereits.');
+ $notes = null;
+ if (array_key_exists('notes', $body)) {
+ $notes = trim((string) $body['notes']) ?: null;
+ } elseif ($id) {
+ $n = $pdo->prepare('SELECT notes FROM priorities WHERE id = ?');
+ $n->execute([$id]);
+ $notes = $n->fetchColumn() ?: null;
+ }
+ if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) api_error(422, 'INVALID_COLOR', 'Bitte eine gültige Farbe im Format #RRGGBB angeben.');
+
+ if ($id === 0) {
+ $pdo->prepare('INSERT INTO priorities (name, color, response_hours, resolution_hours, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)')
+ ->execute([$name, $color, $response, $resolution, $sort, $notes, db_now(), db_now()]);
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "Priorität erstellt: {$name} (Mobile App)");
+ } else {
+ $pdo->prepare('UPDATE priorities SET name=?, color=?, response_hours=?, resolution_hours=?, sort_order=?, notes=?, updated_at=? WHERE id=?')
+ ->execute([$name, $color, $response, $resolution, $sort, $notes, db_now(), $id]);
+ $newId = $id;
+ audit_log($staff['id'], "Priorität aktualisiert: {$name} (Mobile App)");
+ }
+ api_json(['id' => $newId]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE priority_id = ?');
+ $stmt->execute([$id]);
+ if ((int) $stmt->fetchColumn() > 0) {
+ api_error(422, 'PRIORITY_IN_USE', 'Diese Priorität wird noch von Tickets verwendet und kann nicht gelöscht werden.');
+ }
+ $pdo->prepare('DELETE FROM priorities WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "Priorität gelöscht: #{$id} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/queues.php b/DeskHub-Webseite/api/staff/v1/queues.php
new file mode 100644
index 00000000..cdac6b6c
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/queues.php
@@ -0,0 +1,42 @@
+ array_map(fn(array $q) => [
+ 'id' => (int) $q['id'],
+ 'name' => $q['name'],
+ 'isShared' => (bool) $q['is_shared'],
+ 'mine' => (bool) $q['mine'],
+ 'ownerName' => $q['owner_name'] ?? null,
+ 'filters' => queue_filters_to_api($q['filters']),
+ ], queues_for($pdo, (int) $staff['id']))]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+try {
+ if ($action === 'save') {
+ $id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared']));
+ api_json(['id' => $id]);
+ }
+ if ($action === 'delete') {
+ $qid = (int) ($body['id'] ?? 0);
+ $stmt = $pdo->prepare('SELECT * FROM saved_queues WHERE id = ?');
+ $stmt->execute([$qid]);
+ $row = $stmt->fetch();
+ if (!$row) api_error(404, 'NOT_FOUND', 'Ansicht nicht gefunden.');
+ if (!queue_can_edit($row, $staff)) api_error(403, 'FORBIDDEN', 'Diese Ansicht gehört einem anderen Mitarbeiter.');
+ $pdo->prepare('DELETE FROM saved_queues WHERE id = ?')->execute([$qid]);
+ api_json(['ok' => true]);
+ }
+} catch (AdminModelException $e) {
+ api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/report_builder.php b/DeskHub-Webseite/api/staff/v1/report_builder.php
new file mode 100644
index 00000000..82a11b4f
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/report_builder.php
@@ -0,0 +1,53 @@
+ für einen gespeicherten Bericht
+ $in = [
+ 'dimension' => $_GET['dimension'] ?? null, 'from' => $_GET['from'] ?? null, 'to' => $_GET['to'] ?? null,
+ 'metrics' => array_filter(explode(',', (string) ($_GET['metrics'] ?? ''))), 'sort' => $_GET['sort'] ?? 'label', 'desc' => !empty($_GET['desc']),
+ 'filters' => array_intersect_key($_GET, report_filter_columns()),
+ ];
+ $savedStmt = $pdo->prepare('SELECT r.*, s.name AS owner FROM saved_reports r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.staff_id = ? OR r.is_shared = 1 ORDER BY r.name');
+ $savedStmt->execute([$staff['id']]);
+ $saved = $savedStmt->fetchAll();
+ if (isset($_GET['saved'])) {
+ foreach ($saved as $sr) if ((int) $sr['id'] === (int) $_GET['saved']) $in = json_decode($sr['config'], true) ?: [];
+ }
+ $report = report_run($pdo, $in, staff_department_ids($staff));
+ api_json([
+ 'config' => $report['config'], 'columns' => $report['columns'], 'rows' => $report['rows'], 'totals' => $report['totals'],
+ 'dimensions' => array_map(fn($k, $d) => ['key' => $k, 'label' => $d[0]], array_keys(report_dimensions($pdo)), report_dimensions($pdo)),
+ 'metrics' => array_map(fn($k, $m) => ['key' => $k, 'label' => $m[0], 'unit' => $m[2]], array_keys(report_metrics($pdo)), report_metrics($pdo)),
+ 'saved' => array_map(fn($sr) => ['id' => (int) $sr['id'], 'name' => $sr['name'], 'isShared' => (bool) $sr['is_shared'], 'mine' => (int) $sr['staff_id'] === (int) $staff['id'], 'ownerName' => $sr['owner']], $saved),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+if ($action === 'save') {
+ $name = trim((string) ($body['name'] ?? ''));
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen für den Bericht angeben.');
+ $cfg = report_clean_config($pdo, (array) ($body['config'] ?? []));
+ $now = db_now();
+ $pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
+ ->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($body['isShared']) ? 1 : 0, $now, $now]);
+ api_json(['id' => (int) $pdo->lastInsertId()]);
+}
+if ($action === 'delete') {
+ $stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
+ $stmt->execute([(int) ($body['id'] ?? 0)]);
+ $row = $stmt->fetch();
+ if (!$row) api_error(404, 'NOT_FOUND', 'Bericht nicht gefunden.');
+ if ((int) $row['staff_id'] !== (int) $staff['id'] && $staff['role'] !== 'admin') api_error(403, 'FORBIDDEN', 'Dieser Bericht gehört einem anderen Mitarbeiter.');
+ $pdo->prepare('DELETE FROM saved_reports WHERE id = ?')->execute([$row['id']]);
+ api_json(['ok' => true]);
+}
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/roles.php b/DeskHub-Webseite/api/staff/v1/roles.php
new file mode 100644
index 00000000..2bb4e4f9
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/roles.php
@@ -0,0 +1,86 @@
+query(
+ 'SELECT r.*, (SELECT COUNT(*) FROM staff s WHERE s.role_id=r.id) + (SELECT COUNT(*) FROM staff_department sd WHERE sd.role_id=r.id) AS staff_count FROM roles r ORDER BY r.name'
+ )->fetchAll();
+
+ api_json([
+ 'permissionKeys' => $rolePermissionKeys,
+ 'defaultAgentPermissions' => default_permissions('agent'),
+ 'items' => array_map(fn(array $r) => [
+ 'id' => (int) $r['id'],
+ 'name' => $r['name'],
+ 'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [],
+ 'staffCount' => (int) $r['staff_count'],
+ 'isActive' => (bool) ($r['is_active'] ?? 1),
+ 'notes' => $r['notes'] ?? null,
+ 'createdAt' => $r['created_at'] ?? null,
+ 'updatedAt' => $r['updated_at'] ?? null,
+ ], $roles),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'save') {
+ $id = (int) ($body['id'] ?? 0);
+ $name = trim((string) ($body['name'] ?? ''));
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
+
+ $incoming = (array) ($body['permissions'] ?? []);
+ $perms = [];
+ foreach ($rolePermissionKeys as $p) {
+ $perms[$p] = !empty($incoming[$p]);
+ }
+ $json = json_encode($perms);
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
+ $dup->execute([$name, $id]);
+ if ((int) $dup->fetchColumn() > 0) api_error(422, 'NAME_TAKEN', 'Eine Rolle mit diesem Namen existiert bereits.');
+ // Fehlende Schlüssel (ältere App-Version) lassen Status/Notizen unverändert
+ $cur = null;
+ if ($id) {
+ $c = $pdo->prepare('SELECT is_active, notes FROM roles WHERE id = ?');
+ $c->execute([$id]);
+ $cur = $c->fetch() ?: null;
+ }
+ $isActive = array_key_exists('isActive', $body) ? (!empty($body['isActive']) ? 1 : 0) : (int) ($cur['is_active'] ?? 1);
+ $notes = array_key_exists('notes', $body) ? (trim((string) $body['notes']) ?: null) : ($cur['notes'] ?? null);
+
+ if ($id === 0) {
+ $pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$name, $json, $isActive, $notes, db_now(), db_now()]);
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "Rolle erstellt: {$name} (Mobile App)");
+ } else {
+ $pdo->prepare('UPDATE roles SET name=?, permissions=?, is_active=?, notes=?, updated_at=? WHERE id=?')->execute([$name, $json, $isActive, $notes, db_now(), $id]);
+ $newId = $id;
+ audit_log($staff['id'], "Rolle aktualisiert: {$name} (Mobile App)");
+ }
+ api_json(['id' => $newId]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('UPDATE staff SET role_id = NULL WHERE role_id = ?')->execute([$id]);
+ $pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE role_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM roles WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "Rolle gelöscht: #{$id} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/sla_plans.php b/DeskHub-Webseite/api/staff/v1/sla_plans.php
new file mode 100644
index 00000000..281c47f0
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/sla_plans.php
@@ -0,0 +1,51 @@
+query(
+ 'SELECT p.*,
+ (SELECT COUNT(*) FROM departments d WHERE d.sla_plan_id = p.id) AS dept_count,
+ (SELECT COUNT(*) FROM help_topics h WHERE h.sla_plan_id = p.id) AS topic_count
+ FROM sla_plans p ORDER BY p.name'
+ )->fetchAll();
+ api_json(['items' => array_map(fn(array $p) => [
+ 'id' => (int) $p['id'],
+ 'name' => $p['name'],
+ 'responseHours' => (int) $p['response_hours'],
+ 'resolutionHours' => (int) $p['resolution_hours'],
+ 'isActive' => (bool) $p['is_active'],
+ 'notes' => $p['notes'],
+ 'departmentCount' => (int) $p['dept_count'],
+ 'helpTopicCount' => (int) $p['topic_count'],
+ 'updatedAt' => $p['updated_at'],
+ ], $plans)]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$id = (int) ($body['id'] ?? 0);
+
+try {
+ if ($action === 'save') {
+ $in = ['name' => $body['name'] ?? '', 'response_hours' => $body['responseHours'] ?? 0, 'resolution_hours' => $body['resolutionHours'] ?? 0, 'notes' => $body['notes'] ?? ''];
+ if (array_key_exists('isActive', $body)) $in['is_active'] = $body['isActive'];
+ $newId = sla_plan_save($pdo, $id, $in);
+ audit_log($staff['id'], ($id === 0 ? 'SLA-Plan erstellt: ' : 'SLA-Plan aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
+ api_json(['id' => $newId]);
+ }
+ if ($action === 'delete') {
+ $deleted = sla_plan_delete($pdo, $id);
+ audit_log($staff['id'], "SLA-Plan gelöscht: {$deleted} (Mobile App)");
+ api_json(['ok' => true]);
+ }
+} catch (AdminModelException $e) {
+ api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/staff_manage.php b/DeskHub-Webseite/api/staff/v1/staff_manage.php
new file mode 100644
index 00000000..a40d7d01
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/staff_manage.php
@@ -0,0 +1,163 @@
+prepare('DELETE FROM staff_department WHERE staff_id = ?')->execute([$staffId]);
+ $ins = $pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,?,?)');
+ $validRoles = array_map('intval', $pdo->query('SELECT id FROM roles')->fetchAll(PDO::FETCH_COLUMN));
+ $seen = [];
+ foreach ($entries as $entry) {
+ $dId = (int) ($entry['departmentId'] ?? 0);
+ if ($dId === 0 || $dId === (int) $primaryDeptId || isset($seen[$dId])) continue;
+ $seen[$dId] = true;
+ $rid = (int) ($entry['roleId'] ?? 0);
+ $ins->execute([$staffId, $dId, !empty($entry['notify']) ? 1 : 0, in_array($rid, $validRoles, true) ? $rid : null]);
+ }
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'GET') {
+ $allStaff = $pdo->query(
+ 'SELECT s.*, d.name AS department_name FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name'
+ )->fetchAll();
+ $departments = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
+ $roles = $pdo->query('SELECT id, name FROM roles ORDER BY name')->fetchAll();
+ $teams = $pdo->query('SELECT id, name FROM teams ORDER BY name')->fetchAll();
+ $teamStmt = $pdo->prepare('SELECT team_id FROM team_members WHERE staff_id = ?');
+
+ $extraStmt = $pdo->prepare('SELECT department_id, notify, role_id FROM staff_department WHERE staff_id = ?');
+ $items = [];
+ foreach ($allStaff as $s) {
+ $extraStmt->execute([$s['id']]);
+ $extra = [];
+ foreach ($extraStmt->fetchAll() as $row) {
+ $extra[] = ['departmentId' => (int) $row['department_id'], 'notify' => (bool) $row['notify'], 'roleId' => $row['role_id'] !== null ? (int) $row['role_id'] : null];
+ }
+ $teamStmt->execute([$s['id']]);
+ $items[] = [
+ 'id' => (int) $s['id'],
+ 'name' => $s['name'],
+ 'email' => $s['email'],
+ 'role' => $s['role'],
+ 'roleId' => $s['role_id'] !== null ? (int) $s['role_id'] : null,
+ 'departmentId' => $s['department_id'] !== null ? (int) $s['department_id'] : null,
+ 'departmentName' => $s['department_name'],
+ 'isActive' => (bool) $s['is_active'],
+ 'signature' => $s['signature'],
+ 'extraDepartments' => $extra,
+ 'teamIds' => array_map('intval', $teamStmt->fetchAll(PDO::FETCH_COLUMN)),
+ 'createdAt' => $s['created_at'] ?? null,
+ ];
+ }
+
+ api_json([
+ 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
+ 'roles' => array_map(fn(array $r) => ['id' => (int) $r['id'], 'name' => $r['name']], $roles),
+ 'teams' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['name']], $teams),
+ 'canManageTeams' => has_permission($me, 'manage_teams'),
+ 'items' => $items,
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'save') {
+ $id = (int) ($body['id'] ?? 0);
+ $name = trim((string) ($body['name'] ?? ''));
+ $email = trim((string) ($body['email'] ?? ''));
+ // Nur echte Administratoren dürfen die Administrator-Rolle vergeben – sonst könnte sich ein
+ // per Co-Admin-Recht (nur manage_staff) berechtigter Agent selbst (oder andere) zum
+ // Vollzugriff-Admin machen. Beim Bearbeiten durch einen Co-Admin bleibt die vorhandene
+ // Rolle des Ziel-Kontos unverändert, statt sie stillschweigend auf "Mitarbeiter" zu setzen.
+ if ($isFullAdmin) {
+ $role = ($body['role'] ?? '') === 'admin' ? 'admin' : 'agent';
+ } elseif ($id !== 0) {
+ $currentRoleStmt = $pdo->prepare('SELECT role FROM staff WHERE id = ?');
+ $currentRoleStmt->execute([$id]);
+ $role = $currentRoleStmt->fetchColumn() === 'admin' ? 'admin' : 'agent';
+ } else {
+ $role = 'agent';
+ }
+ $roleId = isset($body['roleId']) && $body['roleId'] !== null ? (int) $body['roleId'] : null;
+ $deptId = isset($body['departmentId']) && $body['departmentId'] !== null ? (int) $body['departmentId'] : null;
+ $isActive = !empty($body['isActive']) ? 1 : 0;
+ $password = (string) ($body['password'] ?? '');
+ $signature = sanitize_staff_html((string) ($body['signature'] ?? ''));
+
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) api_error(422, 'INVALID_EMAIL', 'Bitte eine gültige E-Mail-Adresse angeben.');
+
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id <> ?');
+ $dup->execute([$email, $id]);
+ if ((int) $dup->fetchColumn() > 0) {
+ api_error(422, 'EMAIL_TAKEN', 'Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits.');
+ }
+ if ($id !== 0 && $id === (int) $me['id'] && !$isActive) {
+ api_error(422, 'CANNOT_DEACTIVATE_SELF', 'Sie können Ihr eigenes Konto nicht deaktivieren.');
+ }
+
+ if ($id === 0) {
+ $pwErr = validate_password_strength($password);
+ if ($pwErr) api_error(422, 'WEAK_PASSWORD', $pwErr);
+
+ $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, role_id, department_id, signature, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
+ ->execute([$name, $email, password_hash($password, PASSWORD_DEFAULT), $role, $roleId, $deptId, $signature ?: null, $isActive, db_now()]);
+ $newId = (int) $pdo->lastInsertId();
+ audit_log($me['id'], "Mitarbeiter angelegt: {$name} ({$email}) (Mobile App)");
+ } else {
+ $newId = $id;
+ if ($password !== '') {
+ $pwErr = validate_password_strength($password);
+ if ($pwErr) api_error(422, 'WEAK_PASSWORD', $pwErr);
+ $pdo->prepare('UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=?, password_hash=? WHERE id=?')
+ ->execute([$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive, password_hash($password, PASSWORD_DEFAULT), $id]);
+ } else {
+ $pdo->prepare('UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=? WHERE id=?')
+ ->execute([$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive, $id]);
+ }
+ audit_log($me['id'], "Mitarbeiter aktualisiert: {$name} ({$email}) (Mobile App)");
+ }
+ // Optional im selben Aufruf: erweiterter Zugriff und Team-Mitgliedschaften
+ if (array_key_exists('extraDepartments', $body)) {
+ api_save_staff_access($pdo, $newId, $deptId, (array) $body['extraDepartments']);
+ }
+ if (array_key_exists('teamIds', $body) && has_permission($me, 'manage_teams')) {
+ $pdo->prepare('DELETE FROM team_members WHERE staff_id = ?')->execute([$newId]);
+ $ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)');
+ foreach (array_unique(array_filter(array_map('intval', (array) $body['teamIds']))) as $tid) $ins->execute([$tid, $newId]);
+ }
+ api_json(['id' => $newId]);
+}
+
+if ($action === 'save_access') {
+ $id = (int) ($body['id'] ?? 0);
+ $stmt = $pdo->prepare('SELECT department_id FROM staff WHERE id = ?');
+ $stmt->execute([$id]);
+ $primaryDeptId = (int) $stmt->fetchColumn();
+
+ api_save_staff_access($pdo, $id, $primaryDeptId, (array) ($body['extraDepartments'] ?? []));
+ audit_log($me['id'], "Abteilungszugriff geändert für Mitarbeiter #{$id} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ if ($id === (int) $me['id']) {
+ api_error(422, 'CANNOT_DELETE_SELF', 'Sie können Ihr eigenes Konto nicht löschen.');
+ }
+ $pdo->prepare('UPDATE tickets SET assigned_staff_id = NULL WHERE assigned_staff_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM staff WHERE id = ?')->execute([$id]);
+ audit_log($me['id'], "Mitarbeiter gelöscht: #{$id} (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/tasks.php b/DeskHub-Webseite/api/staff/v1/tasks.php
new file mode 100644
index 00000000..aa77299c
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/tasks.php
@@ -0,0 +1,93 @@
+prepare($sql);
+ $stmt->execute($params);
+ $tasks = $stmt->fetchAll();
+
+ $allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+
+ api_json([
+ 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff),
+ 'items' => array_map(fn(array $t) => [
+ 'id' => (int) $t['id'],
+ 'title' => $t['title'],
+ 'description' => $t['description'],
+ 'status' => $t['status'],
+ 'assignedToId' => $t['assigned_to'] !== null ? (int) $t['assigned_to'] : null,
+ 'assignedName' => $t['assigned_name'],
+ 'creatorName' => $t['creator_name'],
+ 'dueAt' => $t['due_at'],
+ 'ticketNumber' => $t['ticket_number'],
+ 'ticketSubject' => $t['ticket_subject'],
+ ], $tasks),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'create') {
+ $title = trim((string) ($body['title'] ?? ''));
+ $desc = trim((string) ($body['description'] ?? ''));
+ $assignTo = isset($body['assignedToId']) && $body['assignedToId'] !== null ? (int) $body['assignedToId'] : null;
+ $dueAt = trim((string) ($body['dueAt'] ?? '')) ?: null;
+ $ticketNumber = trim((string) ($body['ticketNumber'] ?? ''));
+
+ if ($title === '') api_error(422, 'TITLE_REQUIRED', 'Bitte einen Titel angeben.');
+
+ $ticketId = null;
+ if ($ticketNumber !== '') {
+ $tStmt = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
+ $tStmt->execute([$ticketNumber]);
+ $ticketId = $tStmt->fetchColumn() ?: null;
+ if ($ticketId === null) api_error(422, 'TICKET_NOT_FOUND', 'Das verknüpfte Ticket wurde nicht gefunden.');
+ $ticketId = (int) $ticketId;
+ }
+
+ $pdo->prepare('INSERT INTO tasks (ticket_id,title,description,assigned_to,due_at,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)')
+ ->execute([$ticketId, $title, $desc ?: null, $assignTo, $dueAt, $staff['id'], db_now(), db_now()]);
+ api_json(['id' => (int) $pdo->lastInsertId()]);
+}
+
+if ($action === 'update_status') {
+ $id = (int) ($body['id'] ?? 0);
+ $status = in_array($body['status'] ?? '', ['open', 'in_progress', 'done'], true) ? $body['status'] : 'open';
+ $pdo->prepare('UPDATE tasks SET status=?, updated_at=? WHERE id=?')->execute([$status, db_now(), $id]);
+ api_json(['ok' => true]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('DELETE FROM tasks WHERE id=?')->execute([$id]);
+ api_json(['ok' => true]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/teams.php b/DeskHub-Webseite/api/staff/v1/teams.php
new file mode 100644
index 00000000..3e0042a6
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/teams.php
@@ -0,0 +1,69 @@
+query(
+ 'SELECT t.*, s.name AS lead_name,
+ (SELECT COUNT(*) FROM team_members tm WHERE tm.team_id = t.id) AS member_count
+ FROM teams t LEFT JOIN staff s ON s.id = t.lead_id ORDER BY t.name'
+ )->fetchAll();
+ $allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+
+ $memberStmt = $pdo->prepare('SELECT staff_id FROM team_members WHERE team_id = ?');
+ $teamsOut = [];
+ foreach ($teams as $t) {
+ $memberStmt->execute([$t['id']]);
+ $teamsOut[] = [
+ 'id' => (int) $t['id'],
+ 'name' => $t['name'],
+ 'leadId' => $t['lead_id'] !== null ? (int) $t['lead_id'] : null,
+ 'leadName' => $t['lead_name'],
+ 'notes' => $t['notes'],
+ 'isActive' => (bool) $t['is_active'],
+ 'noAlerts' => (bool) ($t['no_alerts'] ?? 0),
+ 'createdAt' => $t['created_at'] ?? null,
+ 'updatedAt' => $t['updated_at'] ?? null,
+ 'memberCount' => (int) $t['member_count'],
+ 'memberIds' => array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN)),
+ ];
+ }
+
+ api_json([
+ 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff),
+ 'items' => $teamsOut,
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+$id = (int) ($body['id'] ?? 0);
+
+try {
+ if ($action === 'save') {
+ $in = ['name' => $body['name'] ?? '', 'lead_id' => $body['leadId'] ?? null, 'notes' => $body['notes'] ?? '', 'members' => $body['memberIds'] ?? []];
+ if (array_key_exists('isActive', $body)) $in['is_active'] = $body['isActive'];
+ if (array_key_exists('noAlerts', $body)) $in['no_alerts'] = $body['noAlerts'];
+ $newId = team_save($pdo, $id, $in);
+ audit_log($staff['id'], ($id === 0 ? 'Team erstellt: ' : 'Team aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
+ api_json(['id' => $newId]);
+ }
+ if ($action === 'toggle') {
+ $pdo->prepare('UPDATE teams SET is_active = 1 - is_active, updated_at = ? WHERE id = ?')->execute([db_now(), $id]);
+ api_json(['ok' => true]);
+ }
+ if ($action === 'delete') {
+ $deleted = team_delete($pdo, $id);
+ audit_log($staff['id'], "Team gelöscht: {$deleted} (Mobile App)");
+ api_json(['ok' => true]);
+ }
+} catch (AdminModelException $e) {
+ api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/ticket.php b/DeskHub-Webseite/api/staff/v1/ticket.php
new file mode 100644
index 00000000..d464ffc0
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/ticket.php
@@ -0,0 +1,93 @@
+prepare(
+ 'SELECT tt.*, s.name AS staff_name, u.name AS user_name
+ FROM ticket_threads tt
+ LEFT JOIN staff s ON s.id = tt.staff_id
+ LEFT JOIN users u ON u.id = tt.user_id
+ WHERE tt.ticket_id = ?
+ ORDER BY tt.created_at ASC, tt.id ASC'
+);
+$threadStmt->execute([$ticket['id']]);
+$attachmentStmt = $pdo->prepare(
+ 'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id'
+);
+$commentStmt = $pdo->prepare(
+ 'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name
+ FROM ticket_thread_comments tc
+ JOIN staff s ON s.id = tc.staff_id
+ WHERE tc.thread_id = ? ORDER BY tc.created_at, tc.id'
+);
+$threads = [];
+foreach ($threadStmt->fetchAll() as $row) {
+ $attachmentStmt->execute([$row['id']]);
+ $commentStmt->execute([$row['id']]);
+ $threads[] = [
+ 'id' => (int) $row['id'],
+ 'authorType' => $row['author_type'],
+ 'authorName' => $row['staff_name'] ?: $row['user_name'] ?: $ticket['requester_name'],
+ 'message' => api_plain_text($row['message']),
+ 'isInternalNote' => (bool) $row['is_internal_note'],
+ 'createdAt' => $row['created_at'],
+ 'attachments' => array_map(fn(array $item) => [
+ 'id' => (int) $item['id'],
+ 'name' => $item['original_name'],
+ 'size' => (int) $item['filesize'],
+ ], $attachmentStmt->fetchAll()),
+ 'comments' => array_map(fn(array $item) => [
+ 'id' => (int) $item['id'],
+ 'message' => api_plain_text($item['message']),
+ 'staff' => ['id' => (int) $item['staff_id'], 'name' => $item['staff_name']],
+ 'createdAt' => $item['created_at'],
+ ], $commentStmt->fetchAll()),
+ ];
+}
+
+// Live-Chat-Sperre: wie im Web-Backend (staff/live_chat.php) "besitzt" der Mitarbeiter,
+// der den Chat gerade ansieht, ihn für ein paar Minuten – verhindert, dass App und Web
+// gleichzeitig unbemerkt im selben Chat antworten. Nur für Live-Chat-Tickets relevant,
+// normale Tickets bleiben unangetastet. Lesen bleibt für alle möglich, nur das
+// Schreiben wird in api_add_ticket_message() serverseitig blockiert.
+$lockedByOtherName = null;
+if ($ticket['source'] === 'chat' && $ticket['status'] !== 'closed') {
+ $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
+ if (!$lockedByOtherName) {
+ live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo);
+ }
+}
+
+$summary = api_ticket_summary($ticket);
+$summary['source'] = $ticket['source'];
+$summary['firstResponseDueAt'] = $ticket['first_response_due_at'];
+$summary['resolutionDueAt'] = $ticket['resolution_due_at'];
+$summary['lockedByOtherName'] = $lockedByOtherName;
+$summary['threads'] = $threads;
+// Rechte für GENAU dieses Ticket: gelten pro Abteilung (Rolle des Mitarbeiters in der Abteilung des Tickets)
+$summary['permissions'] = [];
+foreach (['reply_ticket', 'post_note', 'close_ticket', 'assign_ticket', 'transfer_ticket', 'delete_ticket'] as $__perm) {
+ $summary['permissions'][$__perm] = has_permission($staff, $__perm, (int) $ticket['department_id']);
+}
+api_json(['ticket' => $summary]);
diff --git a/DeskHub-Webseite/api/staff/v1/ticket_create.php b/DeskHub-Webseite/api/staff/v1/ticket_create.php
new file mode 100644
index 00000000..5fde3b77
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/ticket_create.php
@@ -0,0 +1,108 @@
+prepare('SELECT * FROM departments WHERE id = ?');
+$deptStmt->execute([$departmentId]);
+$department = $deptStmt->fetch();
+if ($department && empty($department['is_active'])) {
+ api_error(422, 'DEPARTMENT_INACTIVE', 'Diese Abteilung ist deaktiviert. Bitte eine aktive Abteilung wählen.');
+}
+if (!$department) {
+ api_error(422, 'INVALID_DEPARTMENT', 'Die Abteilung ist ungültig.');
+}
+
+$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
+$prioStmt->execute([$priorityId]);
+$priority = $prioStmt->fetch();
+if (!$priority) {
+ api_error(422, 'INVALID_PRIORITY', 'Die Priorität ist ungültig.');
+}
+
+$sla = calc_sla_dates($priority, null, (int) $departmentId, null);
+$now = db_now();
+$ticketNumber = generate_ticket_number($pdo);
+
+$userId = null;
+if ($requesterEmail !== '') {
+ $userStmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
+ $userStmt->execute([$requesterEmail]);
+ $userId = $userStmt->fetchColumn() ?: null;
+}
+
+$pdo->beginTransaction();
+try {
+ $pdo->prepare(
+ 'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id,
+ priority_id, status, source, assigned_staff_id,
+ first_response_due_at, resolution_due_at, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
+ )->execute([
+ $ticketNumber, $subject, $userId, $requesterName, $requesterEmail, $departmentId,
+ $priorityId, 'open', 'staff', $assignedStaffId,
+ $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
+ ]);
+ $ticketId = (int) $pdo->lastInsertId();
+
+ $pdo->prepare(
+ 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
+ VALUES (?,?,?,?,0,?)'
+ )->execute([$ticketId, 'staff', $staff['id'], $message, $now]);
+
+ log_activity($pdo, $ticketId, (int) $staff['id'], 'Ticket durch Mitarbeiter erstellt (Mobile App)');
+ $pdo->commit();
+} catch (Throwable $e) {
+ if ($pdo->inTransaction()) $pdo->rollBack();
+ throw $e;
+}
+
+if ($requesterEmail !== '' && $notifyCustomer) {
+ notify_customer_ticket_created([
+ 'ticket_number' => $ticketNumber,
+ 'subject' => $subject,
+ 'requester_name' => $requesterName,
+ 'requester_email' => $requesterEmail,
+ 'department_id' => $departmentId,
+ 'department_name' => $department['name'],
+ ]);
+}
+
+$ticket = api_find_ticket($pdo, $staff, $ticketNumber);
+webhook_dispatch($pdo, 'ticket.created', (int) $ticket['id']);
+api_json(['ticket' => api_ticket_summary($ticket)], 201);
diff --git a/DeskHub-Webseite/api/staff/v1/ticket_message.php b/DeskHub-Webseite/api/staff/v1/ticket_message.php
new file mode 100644
index 00000000..7efee484
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/ticket_message.php
@@ -0,0 +1,18 @@
+ $threadId], 201);
diff --git a/DeskHub-Webseite/api/staff/v1/ticket_rules.php b/DeskHub-Webseite/api/staff/v1/ticket_rules.php
new file mode 100644
index 00000000..7b20f10f
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/ticket_rules.php
@@ -0,0 +1,176 @@
+ 'Betreff', 'message' => 'Nachricht', 'requester_email' => 'Absender-E-Mail', 'requester_name' => 'Absender-Name', 'source' => 'Kanal (web/email)'];
+const RULE_OPERATORS = ['contains' => 'enthält', 'not_contains' => 'enthält nicht', 'equals' => 'ist genau', 'starts_with' => 'beginnt mit', 'ends_with' => 'endet mit'];
+const RULE_ACTIONS = ['set_department' => 'Abteilung setzen', 'set_priority' => 'Priorität setzen', 'set_help_topic' => 'Hilfe-Thema setzen', 'assign_staff' => 'Mitarbeiter zuweisen', 'add_tag' => 'Tag hinzufügen', 'auto_reply' => 'Auto-Antwort senden', 'reject' => 'Ticket verwerfen (z.B. Spam)'];
+
+if ($_SERVER['REQUEST_METHOD'] === 'GET') {
+ $departments = departments_for_select($pdo, array_map('intval', $pdo->query("SELECT action_value FROM ticket_rule_actions WHERE action_type = 'set_department'")->fetchAll(PDO::FETCH_COLUMN)));
+ $priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+ $helpTopics = $pdo->query('SELECT id, name FROM help_topics ORDER BY name')->fetchAll();
+ $staffList = $pdo->query("SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll();
+ $cannedResponses = $pdo->query('SELECT id, title FROM canned_responses ORDER BY title')->fetchAll();
+
+ $deptById = array_column($departments, 'name', 'id');
+ $prioById = array_column($priorities, 'name', 'id');
+ $topicById = array_column($helpTopics, 'name', 'id');
+ $staffById = array_column($staffList, 'name', 'id');
+ $cannedById = array_column($cannedResponses, 'title', 'id');
+
+ $rules = $pdo->query('SELECT * FROM ticket_rules ORDER BY sort_order, id')->fetchAll();
+ $condStmt = $pdo->prepare('SELECT field, operator, value FROM ticket_rule_conditions WHERE rule_id = ? ORDER BY id');
+ $actStmt = $pdo->prepare('SELECT action_type, action_value FROM ticket_rule_actions WHERE rule_id = ? ORDER BY id');
+
+ $rulesOut = [];
+ foreach ($rules as $r) {
+ $condStmt->execute([$r['id']]);
+ $actStmt->execute([$r['id']]);
+
+ $actionsOut = [];
+ foreach ($actStmt->fetchAll() as $a) {
+ $type = $a['action_type'];
+ $value = $a['action_value'];
+ $label = match ($type) {
+ 'set_department' => $deptById[$value] ?? null,
+ 'set_priority' => $prioById[$value] ?? null,
+ 'set_help_topic' => $topicById[$value] ?? null,
+ 'assign_staff' => $staffById[$value] ?? null,
+ 'auto_reply' => $cannedById[$value] ?? null,
+ 'add_tag' => $value,
+ 'reject' => null,
+ default => null,
+ };
+ $actionsOut[] = ['type' => $type, 'value' => $value, 'valueLabel' => $label];
+ }
+
+ $rulesOut[] = [
+ 'id' => (int) $r['id'],
+ 'name' => $r['name'],
+ 'matchType' => $r['match_type'],
+ 'isActive' => (bool) $r['is_active'],
+ 'sortOrder' => (int) $r['sort_order'],
+ 'conditions' => $condStmt->fetchAll(),
+ 'actions' => $actionsOut,
+ ];
+ }
+
+ api_json([
+ 'fieldOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_FIELDS), RULE_FIELDS),
+ 'operatorOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_OPERATORS), RULE_OPERATORS),
+ 'actionOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_ACTIONS), RULE_ACTIONS),
+ 'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
+ 'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
+ 'helpTopics' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['name']], $helpTopics),
+ 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffList),
+ 'cannedResponses' => array_map(fn(array $c) => ['id' => (int) $c['id'], 'name' => $c['title']], $cannedResponses),
+ 'rules' => $rulesOut,
+ 'autoCloseEnabled' => get_setting('auto_close_enabled', '0') === '1',
+ 'autoCloseDays' => (int) get_setting('auto_close_days', '7'),
+ ]);
+}
+
+$body = api_request_body();
+$action = $body['action'] ?? '';
+
+if ($action === 'toggle') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('UPDATE ticket_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
+ api_json(['ok' => true]);
+}
+
+if ($action === 'save_auto_close') {
+ $enabled = !empty($body['enabled']) ? '1' : '0';
+ $days = max(1, min(90, (int) ($body['days'] ?? 7)));
+ set_setting('auto_close_enabled', $enabled);
+ set_setting('auto_close_days', (string) $days);
+ audit_log($staff['id'], 'Automatisches Schließen geändert (Mobile App)');
+ api_json(['ok' => true]);
+}
+
+if ($action === 'delete') {
+ $id = (int) ($body['id'] ?? 0);
+ $pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM ticket_rules WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "Ticket-Regel gelöscht: #$id (Mobile App)");
+ api_json(['ok' => true]);
+}
+
+if ($action === 'save') {
+ $id = (int) ($body['id'] ?? 0);
+ $name = trim((string) ($body['name'] ?? ''));
+ $matchType = ($body['matchType'] ?? 'all') === 'any' ? 'any' : 'all';
+ $isActive = !empty($body['isActive']) ? 1 : 0;
+ $sortOrder = (int) ($body['sortOrder'] ?? 0);
+ $rawConditions = is_array($body['conditions'] ?? null) ? $body['conditions'] : [];
+ $rawActions = is_array($body['actions'] ?? null) ? $body['actions'] : [];
+
+ if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
+
+ $conditions = [];
+ foreach ($rawConditions as $c) {
+ $field = (string) ($c['field'] ?? '');
+ $operator = (string) ($c['operator'] ?? '');
+ $value = trim((string) ($c['value'] ?? ''));
+ if ($value === '' || !isset(RULE_FIELDS[$field]) || !isset(RULE_OPERATORS[$operator])) continue;
+ $conditions[] = ['field' => $field, 'operator' => $operator, 'value' => $value];
+ }
+ if (!$conditions) api_error(422, 'CONDITIONS_REQUIRED', 'Bitte mindestens eine Bedingung mit einem Wert angeben.');
+
+ $actionsToSave = [];
+ foreach ($rawActions as $a) {
+ $type = (string) ($a['type'] ?? '');
+ if (!isset(RULE_ACTIONS[$type])) continue;
+ $value = $type === 'reject' ? '1' : trim((string) ($a['value'] ?? ''));
+ if ($type !== 'reject' && $value === '') continue;
+ $actionsToSave[] = ['type' => $type, 'value' => $value];
+ }
+ if (!$actionsToSave) api_error(422, 'ACTIONS_REQUIRED', 'Bitte mindestens eine Aktion festlegen.');
+
+ if ($id) {
+ $existsStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_rules WHERE id = ?');
+ $existsStmt->execute([$id]);
+ if (!(bool) $existsStmt->fetchColumn()) api_error(404, 'RULE_NOT_FOUND', 'Die Regel wurde nicht gefunden.');
+ $pdo->prepare('UPDATE ticket_rules SET name=?, match_type=?, is_active=?, sort_order=? WHERE id=?')
+ ->execute([$name, $matchType, $isActive, $sortOrder, $id]);
+ $pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$id]);
+ audit_log($staff['id'], "Ticket-Regel aktualisiert: $name (Mobile App)");
+ $ruleId = $id;
+ } else {
+ $pdo->prepare('INSERT INTO ticket_rules (name, match_type, is_active, sort_order, created_at) VALUES (?,?,?,?,?)')
+ ->execute([$name, $matchType, $isActive, $sortOrder, db_now()]);
+ $ruleId = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "Ticket-Regel erstellt: $name (Mobile App)");
+ }
+
+ $condStmt = $pdo->prepare('INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES (?,?,?,?)');
+ foreach ($conditions as $c) {
+ $condStmt->execute([$ruleId, $c['field'], $c['operator'], $c['value']]);
+ }
+ $actStmt = $pdo->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)');
+ foreach ($actionsToSave as $a) {
+ $actStmt->execute([$ruleId, $a['type'], $a['value']]);
+ }
+
+ api_json(['id' => $ruleId]);
+}
+
+api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
diff --git a/DeskHub-Webseite/api/staff/v1/ticket_spam.php b/DeskHub-Webseite/api/staff/v1/ticket_spam.php
new file mode 100644
index 00000000..688fea9d
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/ticket_spam.php
@@ -0,0 +1,19 @@
+ api_ticket_summary($updated), 'moved' => $result['moved'], 'failed' => $result['failed']]);
diff --git a/DeskHub-Webseite/api/staff/v1/ticket_update.php b/DeskHub-Webseite/api/staff/v1/ticket_update.php
new file mode 100644
index 00000000..5e40aca0
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/ticket_update.php
@@ -0,0 +1,102 @@
+prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
+ $agentStmt->execute([$assignedId]);
+ if (!(bool) $agentStmt->fetchColumn()) {
+ api_error(422, 'INVALID_ASSIGNEE', 'Der ausgewählte Mitarbeiter ist nicht verfügbar.');
+ }
+ }
+ $currentId = $ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id'];
+ if ($assignedId !== $currentId) {
+ api_require_permission($staff, 'assign_ticket', (int) $ticket['department_id']);
+ $updates[] = 'assigned_staff_id = ?';
+ $params[] = $assignedId;
+ }
+}
+
+if (!$updates) {
+ api_json(['ticket' => api_ticket_summary($ticket)]);
+}
+$updates[] = 'updated_at = ?';
+$params[] = $now;
+$params[] = $ticket['id'];
+$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params);
+if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) {
+ [$label] = status_label((string) $body['status']);
+ log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)');
+}
+if (array_key_exists('assignedStaffId', $body)) {
+ log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Zuweisung geändert (Mobile App)');
+}
+if ($chatJustClosed && $ticket['requester_email']) {
+ try {
+ $transcriptStmt = $pdo->prepare(
+ 'SELECT tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staff_name
+ FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
+ WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC'
+ );
+ $transcriptStmt->execute([$ticket['id']]);
+ $transcriptMessages = array_map(fn(array $m) => [
+ 'authorType' => $m['authorType'],
+ 'authorName' => $m['staff_name'],
+ 'message' => $m['message'],
+ 'createdAt' => $m['createdAt'],
+ ], $transcriptStmt->fetchAll());
+ notify_customer_chat_transcript($ticket, $transcriptMessages);
+ } catch (Throwable $e) {
+ error_log('ticket_update (Mobile App) transcript mail: ' . $e->getMessage());
+ }
+}
+$updated = api_find_ticket($pdo, $staff, $number);
+if ($updated['status'] !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]);
+if ((int) ($updated['assigned_staff_id'] ?? 0) !== (int) ($ticket['assigned_staff_id'] ?? 0)) webhook_dispatch($pdo, 'ticket.assigned', (int) $ticket['id']);
+api_json(['ticket' => api_ticket_summary($updated)]);
diff --git a/DeskHub-Webseite/api/staff/v1/tickets.php b/DeskHub-Webseite/api/staff/v1/tickets.php
new file mode 100644
index 00000000..67e36d71
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/tickets.php
@@ -0,0 +1,111 @@
+ in_array($status, MOBILE_API_STATUSES, true)
+));
+if ($statuses) {
+ $where[] = 't.status IN (' . implode(',', array_fill(0, count($statuses), '?')) . ')';
+ array_push($params, ...$statuses);
+}
+
+$department = filter_var($_GET['departmentId'] ?? null, FILTER_VALIDATE_INT);
+if ($department !== false && $department !== null) {
+ if ($departmentIds !== null && !in_array((int) $department, $departmentIds, true)) {
+ api_error(403, 'DEPARTMENT_FORBIDDEN', 'Auf diese Abteilung besteht kein Zugriff.');
+ }
+ $where[] = 't.department_id = ?';
+ $params[] = (int) $department;
+}
+
+$assigned = trim((string) ($_GET['assigned'] ?? ''));
+if ($assigned === 'me') {
+ $where[] = 't.assigned_staff_id = ?';
+ $params[] = $staff['id'];
+} elseif ($assigned === 'unassigned') {
+ $where[] = 't.assigned_staff_id IS NULL';
+} elseif ($assigned !== '' && ctype_digit($assigned)) {
+ $where[] = 't.assigned_staff_id = ?';
+ $params[] = (int) $assigned;
+}
+
+$priority = filter_var($_GET['priorityId'] ?? null, FILTER_VALIDATE_INT);
+if ($priority !== false && $priority !== null) {
+ $where[] = 't.priority_id = ?';
+ $params[] = (int) $priority;
+}
+
+$tag = filter_var($_GET['tagId'] ?? null, FILTER_VALIDATE_INT);
+if ($tag !== false && $tag !== null) {
+ $where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)';
+ $params[] = (int) $tag;
+}
+
+// SLA-Status: siehe staff/tickets.php für dieselbe Logik (Web und App teilen die
+// 4-Stunden-Risiko-Schwelle, damit beide Oberflächen dasselbe "bald fällig" meinen).
+$sla = trim((string) ($_GET['sla'] ?? ''));
+if ($sla !== '') {
+ $now = db_now();
+ $riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
+ if ($sla === 'overdue') {
+ $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
+ $params[] = $now;
+ } elseif ($sla === 'at_risk') {
+ $where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
+ array_push($params, $now, $riskThreshold);
+ } elseif ($sla === 'on_time') {
+ $where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
+ $params[] = $riskThreshold;
+ }
+}
+
+$search = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
+if ($search !== '') {
+ $like = '%' . $search . '%';
+ $where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ?
+ OR t.requester_name LIKE ? OR t.id IN (SELECT ticket_id FROM ticket_threads WHERE message LIKE ?))';
+ array_push($params, $like, $like, $like, $like, $like);
+}
+
+$page = max(1, (int) ($_GET['page'] ?? 1));
+$perPage = max(10, min(100, (int) ($_GET['perPage'] ?? 30)));
+$offset = ($page - 1) * $perPage;
+$from = ' FROM tickets t
+ JOIN priorities p ON p.id = t.priority_id
+ JOIN departments d ON d.id = t.department_id
+ LEFT JOIN staff s ON s.id = t.assigned_staff_id';
+$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
+
+$countStmt = $pdo->prepare('SELECT COUNT(*)' . $from . $whereSql);
+$countStmt->execute($params);
+$total = (int) $countStmt->fetchColumn();
+
+$stmt = $pdo->prepare(
+ 'SELECT t.*, p.name AS priority_name, p.color AS priority_color,
+ d.name AS department_name, s.name AS staff_name'
+ . $from . $whereSql
+ . ' ORDER BY t.updated_at DESC, t.id DESC LIMIT ' . $perPage . ' OFFSET ' . $offset
+);
+$stmt->execute($params);
+api_json([
+ 'items' => array_map('api_ticket_summary', $stmt->fetchAll()),
+ 'page' => $page,
+ 'perPage' => $perPage,
+ 'total' => $total,
+ 'hasMore' => ($offset + $perPage) < $total,
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/time_entries.php b/DeskHub-Webseite/api/staff/v1/time_entries.php
new file mode 100644
index 00000000..29695d28
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/time_entries.php
@@ -0,0 +1,55 @@
+query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
+
+$where = ['te.logged_at BETWEEN ? AND ?'];
+$params = [$filterFrom . ' 00:00:00', $filterTo . ' 23:59:59'];
+
+if ($filterStaffId !== '') {
+ $where[] = 'te.staff_id = ?';
+ $params[] = (int) $filterStaffId;
+}
+
+$stmt = $pdo->prepare(
+ "SELECT te.*, s.name AS staff_name, t.ticket_number, t.subject
+ FROM ticket_time_entries te
+ JOIN staff s ON s.id = te.staff_id
+ JOIN tickets t ON t.id = te.ticket_id
+ WHERE " . implode(' AND ', $where) . "
+ ORDER BY te.logged_at DESC
+ LIMIT 500"
+);
+$stmt->execute($params);
+$entries = $stmt->fetchAll();
+
+$byStaff = [];
+foreach ($entries as $e) {
+ $byStaff[$e['staff_name']] = ($byStaff[$e['staff_name']] ?? 0) + (int) $e['minutes'];
+}
+$totalMinutes = array_sum(array_column($entries, 'minutes'));
+
+api_json([
+ 'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff),
+ 'entries' => array_map(fn(array $e) => [
+ 'id' => (int) $e['id'],
+ 'staffName' => $e['staff_name'],
+ 'ticketNumber' => $e['ticket_number'],
+ 'subject' => $e['subject'],
+ 'minutes' => (int) $e['minutes'],
+ 'note' => $e['note'],
+ 'loggedAt' => $e['logged_at'],
+ ], $entries),
+ 'totalsByStaff' => array_map(fn($name, $mins) => ['staffName' => $name, 'minutes' => $mins], array_keys($byStaff), array_values($byStaff)),
+ 'totalMinutes' => $totalMinutes,
+]);
diff --git a/DeskHub-Webseite/api/staff/v1/upload_image.php b/DeskHub-Webseite/api/staff/v1/upload_image.php
new file mode 100644
index 00000000..b8bd71f6
--- /dev/null
+++ b/DeskHub-Webseite/api/staff/v1/upload_image.php
@@ -0,0 +1,40 @@
+ 5 * 1024 * 1024) {
+ api_error(422, 'FILE_TOO_LARGE', 'Die Datei ist zu groß (max. 5 MB).');
+}
+
+$result = handle_ticket_upload($file);
+if (isset($result['error'])) {
+ api_error(422, 'UPLOAD_FAILED', $result['error']);
+}
+
+audit_log($staff['id'], "Bild hochgeladen: {$result['stored_name']} (Mobile App)");
+
+api_json([
+ 'url' => base_url() . '/uploads/' . $result['stored_name'],
+]);
diff --git a/DeskHub-Webseite/app_downloads/.htaccess b/DeskHub-Webseite/app_downloads/.htaccess
new file mode 100644
index 00000000..47e55385
--- /dev/null
+++ b/DeskHub-Webseite/app_downloads/.htaccess
@@ -0,0 +1,9 @@
+# Nur über staff/app_download.php (mit Anmeldung) abrufbar
+
+ Require all denied
+
+
+ Order deny,allow
+ Deny from all
+
+Options -Indexes
diff --git a/DeskHub-Webseite/app_downloads/index.html b/DeskHub-Webseite/app_downloads/index.html
new file mode 100644
index 00000000..e69de29b
diff --git a/DeskHub-Webseite/assets/avatars/.htaccess b/DeskHub-Webseite/assets/avatars/.htaccess
new file mode 100644
index 00000000..5a928f6d
--- /dev/null
+++ b/DeskHub-Webseite/assets/avatars/.htaccess
@@ -0,0 +1 @@
+Options -Indexes
diff --git a/DeskHub-Webseite/assets/branding/.htaccess b/DeskHub-Webseite/assets/branding/.htaccess
new file mode 100644
index 00000000..c08e9cf6
--- /dev/null
+++ b/DeskHub-Webseite/assets/branding/.htaccess
@@ -0,0 +1,5 @@
+# Verhindert die Ausführung von hochgeladenen Dateien in diesem Verzeichnis
+
+ Require all denied
+
+php_flag engine off
diff --git a/DeskHub-Webseite/assets/css/style.css b/DeskHub-Webseite/assets/css/style.css
new file mode 100644
index 00000000..3aba9d34
--- /dev/null
+++ b/DeskHub-Webseite/assets/css/style.css
@@ -0,0 +1,1060 @@
+/* ==========================================================
+ DeskHub – Stylesheet v2
+ System-Fonts only, kein CDN, DSGVO-konform.
+ ========================================================== */
+
+:root {
+ --ink: #16202a;
+ --ink-soft: #334155;
+ --bg: #e3e9f1;
+ --surface: #ffffff;
+ --primary: #0f6f6a;
+ --primary-dk: #0b5853;
+ --primary-lt: #e6f4f3;
+ --accent: #d97706;
+ --accent-lt: #fef3c7;
+ --danger: #dc2626;
+ --danger-lt: #fef2f2;
+ --success: #16a34a;
+ --success-lt: #f0fdf4;
+ --border: #dde3ec;
+ --border-soft: #edf0f5;
+ --muted: #64748b;
+ --muted-lt: #94a3b8;
+
+ --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Courier New', monospace;
+ --font-display: var(--font-body);
+
+ --radius: 8px;
+ --radius-lg: 12px;
+ --shadow-sm: 0 1px 3px rgba(15,23,42,.06), 0 1px 2px rgba(15,23,42,.04);
+ --shadow: 0 4px 12px rgba(15,23,42,.08), 0 1px 3px rgba(15,23,42,.04);
+ --shadow-lg: 0 12px 28px rgba(15,23,42,.12), 0 4px 8px rgba(15,23,42,.06);
+}
+
+
+/* ── Reset ─────────────────────────────────────────────── */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font-body);
+ font-size: 15px;
+ line-height: 1.55;
+ -webkit-font-smoothing: antialiased;
+}
+
+body { display: flex; flex-direction: column; min-height: 100vh; }
+
+h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -.025em; margin-bottom: .4em; }
+h2 { font-size: 1.25rem; font-weight: 700; letter-spacing: -.02em; margin-bottom: .4em; }
+h3 { font-size: 1rem; font-weight: 700; letter-spacing: -.015em; margin-bottom: .3em; }
+h4 { font-size: .9rem; font-weight: 600; margin-bottom: .3em; }
+
+a { color: var(--primary); text-decoration: none; }
+a:hover { color: var(--primary-dk); text-decoration: underline; }
+
+/* ── App Shell ──────────────────────────────────────────── */
+
+.app-shell { display: flex; min-height: 100vh; }
+
+/* ── Sidebar ─────────────────────────────────────────────── */
+
+.sidebar {
+ width: 240px;
+ flex-shrink: 0;
+ background: var(--ink);
+ display: flex;
+ flex-direction: column;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+}
+
+.sidebar .brand {
+ padding: 20px 18px 18px;
+ font-size: 1.25rem;
+ font-weight: 800;
+ color: #fff;
+ letter-spacing: -.03em;
+ border-bottom: 1px solid #243040;
+ flex-shrink: 0;
+}
+.sidebar .brand span { color: var(--accent); }
+
+/* Top-level nav links */
+.sidebar > nav { padding: 10px 0 4px; }
+.sidebar > nav a {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: #a8b8c8;
+ padding: 9px 18px;
+ font-size: .9rem;
+ font-weight: 500;
+ border-left: 3px solid transparent;
+ transition: background .12s, color .12s;
+}
+.sidebar > nav a:hover {
+ background: rgba(255,255,255,.05);
+ color: #fff;
+ text-decoration: none;
+ border-left-color: transparent;
+}
+.sidebar > nav a.active {
+ background: rgba(15,111,106,.18);
+ color: #fff;
+ border-left-color: var(--primary);
+ font-weight: 600;
+}
+
+/* Admin block */
+.nav-section-label {
+ padding: 14px 18px 4px;
+ font-size: .68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--accent);
+}
+.nav-section-label[role="button"] {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ user-select: none;
+}
+.nav-section-label[role="button"]:hover { color: #f0a847; }
+.nav-toggle-chevron { font-size: .75rem; }
+
+.nav-admin-block {
+ border-top: 1px solid #243040;
+ border-bottom: 1px solid #243040;
+ padding: 4px 0 8px;
+ margin: 8px 0;
+}
+.nav-group-label {
+ display: block;
+ padding: 10px 18px 2px;
+ font-size: .65rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .07em;
+ color: #4e6278;
+}
+.nav-admin-block a {
+ display: block;
+ color: #9aabb9;
+ padding: 7px 18px 7px 22px;
+ font-size: .865rem;
+ font-weight: 400;
+ border-left: 3px solid transparent;
+ transition: background .1s, color .1s;
+}
+.nav-admin-block a:hover {
+ background: rgba(255,255,255,.04);
+ color: #dce8f0;
+ text-decoration: none;
+}
+.nav-admin-block a.active {
+ background: rgba(15,111,106,.14);
+ color: #fff;
+ border-left-color: var(--primary);
+ font-weight: 500;
+}
+
+.sidebar-footer {
+ margin-top: auto;
+ padding: 14px 18px;
+ border-top: 1px solid #243040;
+ font-size: .78rem;
+ color: #4e6278;
+ flex-shrink: 0;
+}
+
+/* ── Main content ───────────────────────────────────────── */
+
+.main {
+ flex: 1;
+ padding: 24px 28px 60px;
+ max-width: 1600px;
+ min-width: 0;
+}
+
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 24px;
+ gap: 16px;
+}
+
+/* ── Cards ──────────────────────────────────────────────── */
+
+.card {
+ background: var(--surface);
+ border: 1px solid #c4cfdd;
+ border-radius: var(--radius-lg);
+ padding: 22px 24px;
+ margin-bottom: 20px;
+ box-shadow: 0 1px 2px rgba(15,23,42,.05), 0 6px 20px rgba(15,23,42,.10);
+}
+
+.card h3 {
+ font-size: .95rem;
+ font-weight: 700;
+ color: var(--ink);
+ padding-bottom: 12px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+/* Settings-Grid */
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-auto-flow: dense;
+ gap: 20px;
+ align-items: start;
+}
+.settings-grid .card { margin-bottom: 0; }
+.settings-grid .settings-card-wide,
+.settings-grid .card-wide { grid-column: 1 / -1; }
+.settings-grid .settings-card-wide form { max-width: 660px; }
+@media (max-width: 900px) { .settings-grid { grid-template-columns: 1fr; } }
+
+/* ── Stat cards ─────────────────────────────────────────── */
+
+.stat-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 16px;
+ margin-bottom: 28px;
+}
+
+.stat-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 20px 20px 18px;
+ box-shadow: var(--shadow-sm);
+ position: relative;
+ overflow: hidden;
+}
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 3px;
+ background: var(--primary);
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+}
+.stat-card.overdue::before { background: var(--danger); }
+.stat-card.assigned::before { background: var(--accent); }
+.stat-card.unassigned::before { background: var(--muted); }
+
+.stat-card .num {
+ font-size: 2.2rem;
+ font-weight: 800;
+ line-height: 1;
+ color: var(--ink);
+ letter-spacing: -.04em;
+ margin-bottom: 6px;
+}
+.stat-card.overdue .num { color: var(--danger); }
+.stat-card .lbl {
+ font-size: .72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: .06em;
+ color: var(--muted);
+}
+
+/* ── Forms ──────────────────────────────────────────────── */
+
+label {
+ display: block;
+ font-size: .82rem;
+ font-weight: 600;
+ color: var(--ink-soft);
+ margin: 16px 0 5px;
+}
+label:first-child { margin-top: 0; }
+
+input[type=text], input[type=email], input[type=password],
+input[type=search], input[type=number], input[type=date],
+input[type=time], select, textarea {
+ width: 100%;
+ padding: 9px 12px;
+ border: 1.5px solid var(--border);
+ border-radius: var(--radius);
+ font-family: var(--font-body);
+ font-size: .9rem;
+ background: var(--surface);
+ color: var(--ink);
+ transition: border-color .15s, box-shadow .15s;
+}
+textarea { resize: vertical; min-height: 110px; }
+input:focus, select:focus, textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(15,111,106,.12);
+}
+input::placeholder, textarea::placeholder { color: var(--muted-lt); }
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--primary);
+ color: #fff;
+ border: 1.5px solid transparent;
+ border-radius: var(--radius);
+ padding: 9px 18px;
+ font-size: .875rem;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: var(--font-body);
+ white-space: nowrap;
+ transition: background .15s, box-shadow .15s, transform .1s;
+ text-decoration: none;
+}
+.btn:hover {
+ background: var(--primary-dk);
+ color: #fff;
+ text-decoration: none;
+ box-shadow: 0 4px 10px rgba(15,111,106,.25);
+}
+.btn:active { transform: translateY(1px); }
+
+.btn.secondary {
+ background: var(--surface);
+ color: var(--ink-soft);
+ border-color: var(--border);
+}
+.btn.secondary:hover {
+ background: var(--bg);
+ box-shadow: none;
+ color: var(--ink);
+}
+.btn.danger {
+ background: var(--danger);
+ border-color: transparent;
+}
+.btn.danger:hover {
+ background: #b91c1c;
+ box-shadow: 0 4px 10px rgba(220,38,38,.25);
+}
+
+.btn-row { display: flex; gap: 10px; margin-top: 20px; flex-wrap: wrap; }
+.btn-row.right { justify-content: flex-end; }
+
+.field-row { display: flex; gap: 14px; }
+.field-row > div { flex: 1; min-width: 0; }
+
+/* Kundenformular: klare, gefuehrte Abschnitte statt einer langen Feldliste */
+body .ticket-create-page {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+body .ticket-create-header {
+ align-items: flex-end;
+ margin-bottom: 30px;
+ padding-bottom: 24px;
+}
+
+body .ticket-create-header h1 {
+ margin-bottom: 8px;
+ font-size: clamp(1.8rem, 3vw, 2.35rem);
+ letter-spacing: -.035em;
+}
+
+body .ticket-create-header .muted {
+ max-width: 56ch;
+ line-height: 1.65;
+}
+
+body .ticket-create-card {
+ padding: 0 !important;
+ overflow: hidden;
+}
+
+body .ticket-create-form {
+ padding: 34px 38px 30px;
+}
+
+body .ticket-form-section {
+ margin-bottom: 30px;
+ padding-bottom: 30px;
+ border-bottom: 1px solid var(--border-soft);
+}
+
+body .ticket-section-label {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ margin: 0 0 21px;
+ color: var(--ink);
+ font-size: .96rem;
+ font-weight: 800;
+ letter-spacing: -.01em;
+}
+
+body .ticket-section-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ border-radius: 50%;
+ background: var(--primary);
+ color: #fff;
+ font-size: .76rem;
+ line-height: 1;
+}
+
+body .ticket-form-section label {
+ margin-top: 0;
+}
+
+body .ticket-form-section .field-row label {
+ margin-top: 0;
+}
+
+body .ticket-form-hint {
+ margin: 10px 0 0;
+ color: var(--muted);
+ font-size: .8rem;
+ line-height: 1.5;
+}
+
+body .ticket-form-hint a {
+ font-weight: 700;
+}
+
+body .ticket-section-optional {
+ color: var(--muted);
+ font-size: .78rem;
+ font-weight: 500;
+}
+
+body .ticket-attachment-section {
+ margin-bottom: 22px;
+ padding-bottom: 0;
+ border-bottom: 0;
+}
+
+body .ticket-form-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 16px;
+ padding-top: 2px;
+}
+
+body .ticket-form-actions .ticket-form-hint {
+ margin: 0;
+}
+
+body .ticket-required {
+ color: var(--danger);
+}
+
+body .ticket-submit {
+ min-width: 170px;
+ justify-content: center;
+ padding: 12px 24px;
+ font-size: .95rem;
+}
+
+/* ── Badges ─────────────────────────────────────────────── */
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ font-size: .72rem;
+ font-weight: 700;
+ padding: 3px 9px;
+ border-radius: 100px;
+ color: #fff;
+ letter-spacing: .01em;
+ white-space: nowrap;
+}
+.badge.outline {
+ background: transparent;
+ border: 1.5px solid currentColor;
+}
+
+/* ── Tables ─────────────────────────────────────────────── */
+
+table { width: 100%; border-collapse: collapse; }
+th {
+ text-align: left;
+ font-size: .73rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ color: var(--muted);
+ padding: 10px 12px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+th:first-child { border-radius: 6px 0 0 0; }
+th:last-child { border-radius: 0 6px 0 0; }
+
+td {
+ padding: 12px 12px;
+ border-bottom: 1px solid var(--border-soft);
+ font-size: .88rem;
+ vertical-align: middle;
+ color: var(--ink-soft);
+}
+tbody tr:last-child td { border-bottom: none; }
+tbody tr:hover td { background: #f8fafc; }
+tr.is-overdue td { background: var(--danger-lt); }
+tr.is-overdue:hover td { background: #fde8e8; }
+
+/* ── Flash messages ─────────────────────────────────────── */
+
+.flash {
+ padding: 12px 16px;
+ border-radius: var(--radius);
+ margin-bottom: 18px;
+ font-size: .875rem;
+ font-weight: 500;
+ border: 1px solid;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+.flash::before { flex-shrink: 0; margin-top: 1px; font-size: 1rem; }
+.flash.error { background: var(--danger-lt); color: #991b1b; border-color: #fecaca; }
+.flash.error::before { content: '✕'; }
+.flash.success { background: var(--success-lt); color: #14532d; border-color: #bbf7d0; }
+.flash.success::before { content: '✓'; }
+.flash.info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
+.flash.info::before { content: 'ℹ'; }
+
+/* ── Ticket number chip ──────────────────────────────────── */
+
+.ticket-no {
+ font-family: var(--font-mono);
+ font-size: .78em;
+ background: var(--primary-lt);
+ color: var(--primary-dk);
+ padding: .2em .55em;
+ border-radius: 5px;
+ font-weight: 600;
+ letter-spacing: .02em;
+}
+
+/* ── Filters bar ─────────────────────────────────────────── */
+
+.filters {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px 16px;
+ box-shadow: var(--shadow-sm);
+}
+.filters label { margin: 0 0 5px 0; font-size: .73rem; }
+.filters > div { min-width: 140px; }
+.filters input, .filters select { font-size: .85rem; padding: 7px 10px; }
+
+/* ── Ticket thread ───────────────────────────────────────── */
+
+.thread-msg {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 16px;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(0,0,0,.06);
+ background: #fff;
+}
+.thread-msg .meta {
+ background: #f0f4f8;
+ padding: 10px 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 1px solid var(--border);
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.thread-msg.from-staff { border-left: 4px solid var(--primary); }
+.thread-msg.from-staff .meta { background: var(--primary-lt); }
+.thread-msg.is-note { border-left: 4px solid #f59e0b; }
+.thread-msg.is-note .meta { background: #fffbeb; }
+.thread-msg .body {
+ padding: 18px 22px;
+ font-size: .95rem;
+ line-height: 1.8;
+ color: #1e293b;
+ word-break: break-word;
+ overflow-wrap: break-word;
+}
+.thread-msg .body a { color: var(--primary); }
+.thread-msg .body ul, .thread-msg .body ol { margin: 4px 0 8px 1.4em; }
+.thread-msg .body p { margin: 0 0 12px; }
+.thread-msg .body p:last-child { margin-bottom: 0; }
+.thread-msg .body br + br { display: block; margin-top: 8px; }
+
+
+.note-flag {
+ display: inline-block;
+ font-size: .68rem;
+ font-weight: 700;
+ color: var(--accent);
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ background: var(--accent-lt);
+ padding: 1px 6px;
+ border-radius: 4px;
+}
+
+/* ── Misc ────────────────────────────────────────────────── */
+
+.muted { color: var(--muted); }
+.empty-state { text-align: center; padding: 52px 20px; color: var(--muted); }
+.divider { border: none; border-top: 1px solid var(--border-soft); margin: 22px 0; }
+
+/* Auth card */
+.auth-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ padding: 40px 20px;
+ flex: 1 0 auto;
+ background-color: #16202a;
+ background-size: cover;
+ background-position: center;
+ width: 100vw;
+ margin-left: calc(50% - 50vw);
+ margin-right: calc(50% - 50vw);
+ margin-top: -32px;
+ margin-bottom: 0;
+}
+.auth-wrap::before {
+ content: '';
+ position: absolute; inset: 0;
+ pointer-events: none;
+}
+.auth-wrap.has-image::after {
+ content: '';
+ position: absolute; inset: 0;
+ background: linear-gradient(160deg, rgba(15,21,28,.80) 0%, rgba(15,21,28,.95) 100%);
+ z-index: 0;
+}
+.auth-wrap .auth-card { z-index: 1; }
+.auth-card {
+ position: relative; z-index: 1;
+ background: #fff;
+ border: none;
+ border-radius: 20px;
+ padding: 44px 40px 36px;
+ width: 100%;
+ max-width: 420px;
+ box-shadow: 0 24px 60px rgba(0,0,0,.35), 0 4px 16px rgba(0,0,0,.2);
+}
+/* Markenblock in Auth-Karten (themefähig) */
+.brand-block { text-align: center; margin-bottom: 18px; }
+.brand-block img { max-height: 42px; max-width: 220px; }
+.brand-block-text {
+ font-family: Georgia, ui-serif, serif;
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: var(--ink);
+}
+.brand-block-text span { color: var(--accent); }
+.brand-block-lg { margin-bottom: 20px; }
+.brand-block-lg img { max-height: 52px; max-width: 200px; }
+.brand-block-lg .brand-block-text { font-size: 1.5rem; }
+
+.auth-card h1 { font-size: 1.55rem; font-weight: 700; margin-bottom: 6px; color: #0f1923; }.auth-card .sub { color: #64748b; font-size: .875rem; margin-bottom: 26px; line-height: 1.5; }
+.auth-card label { color: #374151; font-size: .83rem; font-weight: 600; }
+.auth-card input[type=email],
+.auth-card input[type=password],
+.auth-card input[type=text] {
+ background: #f8fafc; border-color: #e2e8f0; color: #0f1923;
+}
+.auth-card input:focus { background: #fff; border-color: var(--primary); }
+.auth-card a { color: var(--primary); }
+.auth-card .staff-link { color: #475569; font-size: .78rem; }
+.auth-card .staff-link:hover { color: var(--primary); }
+
+/* Login-Seite: Theme-Dateien koennen die markierten Flaechen individuell
+ gestalten, waehrend Layout und Abstaende zentral konsistent bleiben. */
+body .login-screen {
+ display: flex;
+ flex: 1 0 auto;
+ align-items: center;
+ justify-content: center;
+ padding: 56px 20px;
+ background-size: cover;
+ background-position: center;
+}
+
+body .login-screen .login-card {
+ width: 100%;
+ max-width: 430px;
+}
+
+body .login-screen .login-submit {
+ width: 100%;
+ justify-content: center;
+}
+
+body .login-screen .login-forgot {
+ display: inline-block;
+ font-size: .82rem;
+}
+
+body .login-links {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 22px;
+ padding-top: 18px;
+ border-top: 1px solid var(--border-soft);
+ font-size: .84rem;
+ text-align: center;
+}
+
+body .login-guest-links {
+ color: var(--muted-lt);
+}
+
+body .login-staff-link {
+ margin-top: 4px;
+ color: var(--muted) !important;
+ font-size: .78rem;
+}
+
+/* ── Login Split-Layout ─────────────────────────────────── */
+.login-left {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 48px;
+ position: relative;
+ z-index: 1;
+}
+.login-left-inner {
+ max-width: 420px;
+}
+.login-brand-title {
+ font-size: 2rem;
+ font-weight: 800;
+ color: #fff;
+ margin-bottom: 14px;
+ line-height: 1.2;
+}
+.login-brand-sub {
+ color: rgba(255,255,255,.72);
+ font-size: .95rem;
+ line-height: 1.7;
+ margin-bottom: 36px;
+}
+.login-features {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.login-feature {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ background: rgba(255,255,255,.08);
+ border: 1px solid rgba(255,255,255,.12);
+ border-radius: 12px;
+ padding: 14px 18px;
+ backdrop-filter: blur(4px);
+}
+.login-feature-icon {
+ font-size: 1.5rem;
+ flex-shrink: 0;
+}
+.login-feature strong {
+ display: block;
+ color: #fff;
+ font-size: .88rem;
+ font-weight: 600;
+}
+.login-feature span {
+ color: rgba(255,255,255,.6);
+ font-size: .8rem;
+}
+.login-right {
+ width: 440px;
+ flex-shrink: 0;
+ background: #fff !important;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ position: relative;
+ z-index: 2;
+ border-radius: 0;
+}
+.login-form-wrap {
+ width: 100%;
+ max-width: 360px;
+ padding: 52px 40px;
+}
+@media (max-width: 700px) {
+ .auth-wrap { flex-direction: column; }
+ .login-left { display: none; }
+ .login-right { width: 100%; min-height: 100%; }
+ .login-form-wrap { padding: 40px 28px; }
+}
+
+
+/* ── Responsive ──────────────────────────────────────────── */
+
+@media (max-width: 820px) {
+ .app-shell { flex-direction: column; }
+ .sidebar { width: 100%; height: auto; position: static; }
+ .main { padding: 20px 16px 48px; }
+ .field-row { flex-direction: column; gap: 0; }
+ .settings-grid { grid-template-columns: 1fr; }
+ body .ticket-create-page { max-width: none; }
+ body .ticket-create-header { align-items: flex-start; }
+ body .ticket-create-form { padding: 26px 22px 24px; }
+}
+
+/* ================================================================
+ KUNDENPORTAL – horizontale Top-Navigation
+ ================================================================ */
+
+.site-header { background: var(--surface); border-bottom: 1px solid var(--border); }
+.site-header .topbar-thin {
+ background: var(--ink);
+ color: #c4cdd6;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 9px 28px;
+ font-size: .82rem;
+ flex-wrap: wrap; gap: 10px;
+}
+.site-header .topbar-thin .welcome { color: #fff; font-weight: 600; }
+.site-header .topbar-thin nav { display: flex; gap: 20px; flex-wrap: wrap; }
+.site-header .topbar-thin nav a { color: #c4cdd6; }
+.site-header .topbar-thin nav a:hover,
+.site-header .topbar-thin nav a.active { color: #fff; text-decoration: none; }
+.site-header .logo-row { padding: 16px 28px; display: flex; align-items: center; }
+.site-header .logo-row img { max-height: 52px; max-width: 240px; display: block; }
+.site-header .logo-row .text-brand {
+ font-size: 1.6rem; font-weight: 800; color: var(--ink); letter-spacing: -.03em;
+}
+.site-header .logo-row .text-brand span { color: var(--accent); }
+
+/* Hero */
+.hero-search {
+ position: relative;
+ background-color: rgba(15,21,28,.72);
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ color: #fff;
+ padding: 68px 28px 60px;
+ text-align: center;
+ overflow: hidden;
+}
+.hero-search::before {
+ content: '';
+ position: absolute; inset: 0;
+ pointer-events: none;
+}
+.hero-search.has-image::after {
+ content: '';
+ position: absolute; inset: 0;
+ background: linear-gradient(180deg, rgba(15,21,28,.75) 0%, rgba(15,21,28,.75) 70%, rgba(15,21,28,.98) 100%);
+ z-index: 1;
+}
+.hero-search .hero-inner { position: relative; z-index: 2; max-width: 620px; margin: 0 auto; }
+.hero-search .eyebrow {
+ display: inline-block;
+ font-size: .72rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase;
+ color: var(--accent); margin-bottom: 16px;
+}
+.hero-search h1 { font-size: 2.15rem; color: #fff; margin-bottom: 12px; }
+.hero-search .sub { color: #c4cdd6; font-size: .98rem; margin-bottom: 30px; }
+.hero-search .search-form {
+ display: flex;
+ background: #fff;
+ border-radius: 999px;
+ padding: 6px 6px 6px 22px;
+ box-shadow: 0 16px 34px rgba(0,0,0,.28);
+}
+.hero-search .search-form input {
+ flex: 1; border: none; outline: none; font-size: .95rem;
+ background: transparent; color: var(--ink); font-family: var(--font-body);
+}
+.hero-search .search-form button {
+ background: var(--accent); color: #fff; border: none; border-radius: 999px;
+ padding: 11px 24px; font-weight: 600; cursor: pointer;
+ font-family: var(--font-body); font-size: .88rem; white-space: nowrap;
+}
+.hero-search .search-form button:hover { background: #b85f08; }
+.hero-search .quick-links { margin-top: 18px; font-size: .82rem; color: #9aa7b2; }
+.hero-search .quick-links a { color: #d7dee4; }
+.hero-search .quick-links a:hover { color: #fff; }
+
+.page-content {
+ flex: 1 0 auto; width: 100%;
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 32px 28px 60px;
+}
+
+/* CTA cards */
+.cta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin: 36px 0 28px; }
+.cta-card {
+ position: relative;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 32px 26px;
+ text-align: center;
+ transition: transform .15s, box-shadow .15s, border-color .15s;
+}
+.cta-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }
+.cta-card .icon-badge {
+ width: 54px; height: 54px; border-radius: 14px;
+ display: flex; align-items: center; justify-content: center;
+ margin: 0 auto 16px;
+}
+.cta-card .icon-badge svg { width: 26px; height: 26px; }
+.cta-card.primary .icon-badge { background: rgba(15,111,106,.1); }
+.cta-card.primary .icon-badge svg { stroke: var(--primary); }
+.cta-card.secondary .icon-badge { background: rgba(217,119,6,.1); }
+.cta-card.secondary .icon-badge svg { stroke: var(--accent); }
+.cta-card h3 { margin-bottom: 8px; }
+.cta-card p { color: var(--muted); font-size: .88rem; margin-bottom: 18px; min-height: 44px; }
+
+/* KB */
+.kb-search { max-width: 480px; margin: 0 auto 30px; }
+.kb-category-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 20px; margin-bottom: 20px;
+}
+.kb-category-card { margin-bottom: 0; }
+.kb-category-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
+.kb-category-head .icon-badge {
+ width: 44px; height: 44px; border-radius: 12px; flex-shrink: 0;
+ display: flex; align-items: center; justify-content: center;
+ background: rgba(15,111,106,.1);
+}
+.kb-category-head .icon-badge svg { width: 22px; height: 22px; stroke: var(--primary); }
+.kb-article-link {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 10px 0; border-top: 1px solid var(--border);
+ color: var(--ink); font-size: .9rem;
+}
+.kb-article-link:hover { color: var(--primary); text-decoration: none; }
+.kb-article-link .arrow { color: var(--muted); transition: transform .15s; }
+.kb-article-link:hover .arrow { transform: translateX(3px); color: var(--primary); }
+.kb-result-card:hover { box-shadow: var(--shadow); }
+.popular-articles .article-row {
+ display: flex; align-items: baseline; gap: 16px;
+ padding: 14px 2px; border-bottom: 1px solid var(--border);
+}
+.popular-articles .article-row:last-child { border-bottom: none; }
+.popular-articles .rank {
+ font-weight: 800; color: var(--accent); font-size: 1.1rem; width: 22px; flex-shrink: 0;
+}
+.popular-articles .article-title { font-weight: 600; }
+.popular-articles .article-meta { color: var(--muted); font-size: .78rem; margin-top: 2px; }
+
+/* Steps */
+.steps-list { display: flex; flex-direction: column; gap: 16px; }
+.steps-list .step { display: flex; gap: 12px; align-items: flex-start; font-size: .85rem; }
+.steps-list .step-num {
+ flex-shrink: 0; width: 24px; height: 24px;
+ border-radius: 50%; background: rgba(15,111,106,.1);
+ color: var(--primary); font-weight: 800; font-size: .78rem;
+ display: flex; align-items: center; justify-content: center;
+}
+
+footer.public-footer {
+ text-align: center;
+ background: var(--ink);
+ color: #4e6278;
+ font-size: .78rem;
+ padding: 18px 0;
+ margin-top: auto;
+ width: 100%;
+}
+body:has(.auth-wrap) footer.public-footer { margin-top: 0; flex-shrink: 0; }
+
+/* Auth-Seiten: Die Karte soll die volle Resthöhe füllen, statt eine leere
+ Fläche zwischen Karte und Footer zu lassen. */
+body:has(.auth-wrap) .page-content {
+ display: flex;
+ flex-direction: column;
+ max-width: none;
+ padding: 0;
+}
+body:has(.auth-wrap) .page-content > .auth-wrap {
+ width: 100%;
+ margin-left: 0;
+ margin-right: 0;
+ margin-top: 0;
+}
+
+
+/* ── Rich-Text-Editor ───────────────────────────────────────── */
+.richtext-wrap { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--surface); }
+.richtext-toolbar {
+ display: flex; gap: 2px; padding: 6px 8px;
+ background: var(--bg-soft); border-bottom: 1px solid var(--border);
+ flex-wrap: wrap;
+}
+.richtext-toolbar button {
+ background: none; border: 1px solid transparent; border-radius: 4px;
+ padding: 3px 8px; font-size: .82rem; font-weight: 700; cursor: pointer;
+ color: var(--ink-soft); line-height: 1.4; transition: all .15s;
+}
+.richtext-toolbar button:hover { background: var(--border); border-color: var(--border); color: var(--ink); }
+.richtext-toolbar button.active { background: var(--primary-lt); border-color: var(--primary); color: var(--primary); }
+.richtext-editable {
+ min-height: 120px; padding: 10px 12px; outline: none;
+ font-size: .88rem; line-height: 1.6; color: var(--ink);
+ cursor: text;
+}
+.richtext-editable:empty::before { content: attr(data-placeholder); color: var(--muted-lt); pointer-events: none; }
+.richtext-editable ul, .richtext-editable ol { padding-left: 20px; margin: 6px 0; }
+.richtext-editable a { color: var(--primary); }
+
+@media (max-width: 800px) {
+ .site-header .topbar-thin { flex-direction: column; align-items: flex-start; }
+ .cta-grid { grid-template-columns: 1fr; }
+ .hero-search { padding: 48px 18px 42px; }
+ .hero-search h1 { font-size: 1.6rem; }
+ .hero-search .search-form { flex-direction: column; border-radius: 16px; padding: 14px; gap: 10px; }
+ .hero-search .search-form input { text-align: center; }
+ .page-content { padding: 24px 18px 50px; }
+}
+
+/* ── Details/Summary (Weitere Aktionen) ─────────────────── */
+details > summary { outline: none; }
+details > summary::-webkit-details-marker { display: none; }
+details[open] > summary span:last-child { transform: rotate(180deg); display:inline-block; }