From de06066052d3354df4da3fc180121f855f79280c Mon Sep 17 00:00:00 2001
From: M_Viper
Date: Fri, 16 Oct 2026 19:12:21 +0000
Subject: [PATCH] Upload via GUI (258 Dateien)
---
DeskHub-Webseite/staff/app_download.php | 21 +
DeskHub-Webseite/staff/audit_log.php | 85 ++
DeskHub-Webseite/staff/availability.php | 335 ++++++
.../staff/availability_status.php | 33 +
DeskHub-Webseite/staff/backup.php | 350 ++++++
DeskHub-Webseite/staff/backup_download.php | 31 +
DeskHub-Webseite/staff/banlist.php | 168 +++
DeskHub-Webseite/staff/business_hours.php | 205 ++++
DeskHub-Webseite/staff/canned_responses.php | 171 +++
DeskHub-Webseite/staff/catalog_preview.php | 13 +
DeskHub-Webseite/staff/chat.php | 324 +++++
DeskHub-Webseite/staff/custom_fields.php | 172 +++
DeskHub-Webseite/staff/custom_lists.php | 160 +++
DeskHub-Webseite/staff/customer_profile.php | 225 ++++
DeskHub-Webseite/staff/customers.php | 215 ++++
DeskHub-Webseite/staff/dashboard.php | 11 +
DeskHub-Webseite/staff/dashboard_counts.php | 29 +
DeskHub-Webseite/staff/departments.php | 244 ++++
DeskHub-Webseite/staff/email_addresses.php | 383 ++++++
DeskHub-Webseite/staff/email_templates.php | 110 ++
DeskHub-Webseite/staff/escalation_rules.php | 338 ++++++
DeskHub-Webseite/staff/export.php | 130 ++
DeskHub-Webseite/staff/forgot_password.php | 72 ++
DeskHub-Webseite/staff/help_topics.php | 264 ++++
DeskHub-Webseite/staff/imap_accounts.php | 262 ++++
DeskHub-Webseite/staff/import_osticket.php | 353 ++++++
DeskHub-Webseite/staff/kb_manage.php | 197 +++
DeskHub-Webseite/staff/live_chat.php | 614 ++++++++++
DeskHub-Webseite/staff/login.php | 148 +++
DeskHub-Webseite/staff/logout.php | 4 +
DeskHub-Webseite/staff/mentions.php | 57 +
DeskHub-Webseite/staff/organizations.php | 142 +++
DeskHub-Webseite/staff/plugin.php | 29 +
DeskHub-Webseite/staff/plugins.php | 241 ++++
DeskHub-Webseite/staff/priorities.php | 167 +++
DeskHub-Webseite/staff/profile.php | 509 ++++++++
DeskHub-Webseite/staff/push_subscribe.php | 47 +
DeskHub-Webseite/staff/queues.php | 181 +++
DeskHub-Webseite/staff/recurring_tickets.php | 234 ++++
DeskHub-Webseite/staff/report_builder.php | 178 +++
DeskHub-Webseite/staff/reports.php | 758 ++++++++++++
DeskHub-Webseite/staff/reset_password.php | 72 ++
DeskHub-Webseite/staff/roles.php | 198 +++
DeskHub-Webseite/staff/security_check.php | 40 +
DeskHub-Webseite/staff/session_ping.php | 41 +
DeskHub-Webseite/staff/settings.php | 1068 +++++++++++++++++
DeskHub-Webseite/staff/site_pages.php | 167 +++
DeskHub-Webseite/staff/sla_plans.php | 137 +++
DeskHub-Webseite/staff/staff_manage.php | 405 +++++++
DeskHub-Webseite/staff/sw-staff.js | 73 ++
50 files changed, 10411 insertions(+)
create mode 100644 DeskHub-Webseite/staff/app_download.php
create mode 100644 DeskHub-Webseite/staff/audit_log.php
create mode 100644 DeskHub-Webseite/staff/availability.php
create mode 100644 DeskHub-Webseite/staff/availability_status.php
create mode 100644 DeskHub-Webseite/staff/backup.php
create mode 100644 DeskHub-Webseite/staff/backup_download.php
create mode 100644 DeskHub-Webseite/staff/banlist.php
create mode 100644 DeskHub-Webseite/staff/business_hours.php
create mode 100644 DeskHub-Webseite/staff/canned_responses.php
create mode 100644 DeskHub-Webseite/staff/catalog_preview.php
create mode 100644 DeskHub-Webseite/staff/chat.php
create mode 100644 DeskHub-Webseite/staff/custom_fields.php
create mode 100644 DeskHub-Webseite/staff/custom_lists.php
create mode 100644 DeskHub-Webseite/staff/customer_profile.php
create mode 100644 DeskHub-Webseite/staff/customers.php
create mode 100644 DeskHub-Webseite/staff/dashboard.php
create mode 100644 DeskHub-Webseite/staff/dashboard_counts.php
create mode 100644 DeskHub-Webseite/staff/departments.php
create mode 100644 DeskHub-Webseite/staff/email_addresses.php
create mode 100644 DeskHub-Webseite/staff/email_templates.php
create mode 100644 DeskHub-Webseite/staff/escalation_rules.php
create mode 100644 DeskHub-Webseite/staff/export.php
create mode 100644 DeskHub-Webseite/staff/forgot_password.php
create mode 100644 DeskHub-Webseite/staff/help_topics.php
create mode 100644 DeskHub-Webseite/staff/imap_accounts.php
create mode 100644 DeskHub-Webseite/staff/import_osticket.php
create mode 100644 DeskHub-Webseite/staff/kb_manage.php
create mode 100644 DeskHub-Webseite/staff/live_chat.php
create mode 100644 DeskHub-Webseite/staff/login.php
create mode 100644 DeskHub-Webseite/staff/logout.php
create mode 100644 DeskHub-Webseite/staff/mentions.php
create mode 100644 DeskHub-Webseite/staff/organizations.php
create mode 100644 DeskHub-Webseite/staff/plugin.php
create mode 100644 DeskHub-Webseite/staff/plugins.php
create mode 100644 DeskHub-Webseite/staff/priorities.php
create mode 100644 DeskHub-Webseite/staff/profile.php
create mode 100644 DeskHub-Webseite/staff/push_subscribe.php
create mode 100644 DeskHub-Webseite/staff/queues.php
create mode 100644 DeskHub-Webseite/staff/recurring_tickets.php
create mode 100644 DeskHub-Webseite/staff/report_builder.php
create mode 100644 DeskHub-Webseite/staff/reports.php
create mode 100644 DeskHub-Webseite/staff/reset_password.php
create mode 100644 DeskHub-Webseite/staff/roles.php
create mode 100644 DeskHub-Webseite/staff/security_check.php
create mode 100644 DeskHub-Webseite/staff/session_ping.php
create mode 100644 DeskHub-Webseite/staff/settings.php
create mode 100644 DeskHub-Webseite/staff/site_pages.php
create mode 100644 DeskHub-Webseite/staff/sla_plans.php
create mode 100644 DeskHub-Webseite/staff/staff_manage.php
create mode 100644 DeskHub-Webseite/staff/sw-staff.js
diff --git a/DeskHub-Webseite/staff/app_download.php b/DeskHub-Webseite/staff/app_download.php
new file mode 100644
index 00000000..22651954
--- /dev/null
+++ b/DeskHub-Webseite/staff/app_download.php
@@ -0,0 +1,21 @@
+ 0) { @ob_end_clean(); }
+
+header('Content-Type: ' . $info['mime']);
+header('Content-Disposition: attachment; filename="' . $info['filename'] . '"');
+header('Content-Length: ' . $info['size']);
+header('X-Content-Type-Options: nosniff');
+header('Cache-Control: private, no-store');
+readfile($info['path']);
diff --git a/DeskHub-Webseite/staff/audit_log.php b/DeskHub-Webseite/staff/audit_log.php
new file mode 100644
index 00000000..cb91b79b
--- /dev/null
+++ b/DeskHub-Webseite/staff/audit_log.php
@@ -0,0 +1,85 @@
+query('SELECT COUNT(*) FROM admin_audit_log')->fetchColumn();
+ $pdo->exec('DELETE FROM admin_audit_log');
+ audit_log($staff['id'], "Admin-Protokoll geleert ({$countBefore} Einträge entfernt)");
+ flash('success', 'Admin-Protokoll geleert.');
+ redirect('/staff/audit_log.php');
+}
+
+$page = max(1, (int) ($_GET['p'] ?? 1));
+$perPage = 50;
+$offset = ($page - 1) * $perPage;
+
+$total = (int) $pdo->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();
+
+$totalPages = max(1, (int) ceil($total / $perPage));
+
+$pageTitle = 'Admin-Protokoll';
+$activePage = 'audit_log';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
Admin-Protokoll
+
Protokoll administrativer Änderungen (Mitarbeiter, Stammdaten, Einstellungen) – getrennt vom Aktivitätsprotokoll einzelner Tickets.
+
+
+
+
+
+
+
+
+
Noch keine Einträge.
+
+
+ Zeitpunkt Mitarbeiter Aktion
+
+
+
+ = format_dt($e['created_at']) ?>
+ = e($e['staff_name'] ?? 'System') ?>
+ = e($e['action']) ?>
+
+
+
+
+ 1): ?>
+
+ Seite = $page ?> von = $totalPages ?>
+ 1): ?> ·
← Neuer
+ ·
Älter →
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/availability.php b/DeskHub-Webseite/staff/availability.php
new file mode 100644
index 00000000..205ee272
--- /dev/null
+++ b/DeskHub-Webseite/staff/availability.php
@@ -0,0 +1,335 @@
+'CSRF-Fehler', 'got'=>$_POST['csrf_token']??'', 'expected'=>$_SESSION['csrf_token']??'']); exit; }
+
+ $newStatus = $_POST['status'] ?? '';
+ $note = trim($_POST['note'] ?? '');
+ $allowed = ['available', 'busy', 'break', 'offline'];
+
+ if (!in_array($newStatus, $allowed, true)) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Ungültiger Status']);
+ exit;
+ }
+
+ $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ $availabilitySql = $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)';
+
+ try {
+ $pdo->prepare($availabilitySql)->execute([$staff['id'], $newStatus, $note ?: null, db_now()]);
+ echo json_encode(['ok' => true, 'status' => $newStatus]);
+ } catch (Throwable $e) {
+ error_log('staff_availability: ' . $e->getMessage());
+ http_response_code(500);
+ echo json_encode(['error' => 'Status konnte nicht gespeichert werden.']);
+ }
+ exit;
+}
+
+// ── Schichtplan (Admin-Seite) ─────────────────────────────────────────────────
+$admin = $staff['role'] === 'admin' ? $staff : null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'save_shifts' && $admin) {
+ $targetId = (int)($_POST['staff_id'] ?? 0);
+ $__nameStmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
+ $__nameStmt->execute([$targetId]);
+ $editAgentName = $__nameStmt->fetchColumn() ?: "#$targetId";
+ if (!$targetId) {
+ flash('error', 'Ungültiger Mitarbeiter.');
+ redirect('/staff/availability.php');
+ }
+ // Alte Schichten löschen
+ $pdo->prepare('DELETE FROM staff_shifts WHERE staff_id = ?')->execute([$targetId]);
+ // Neue speichern
+ $days = $_POST['days'] ?? [];
+ foreach ($days as $day => $times) {
+ if (empty($times['active'])) continue;
+ $start = $times['start'] ?? '08:00';
+ $end = $times['end'] ?? '17:00';
+ // Keine Zeitvalidierung – Nutzer ist verantwortlich
+ $pdo->prepare(
+ 'INSERT INTO staff_shifts (staff_id, weekday, start_time, end_time, is_active) VALUES (?,?,?,?,1)'
+ )->execute([$targetId, (int)$day, $start, $end]);
+ }
+ audit_log($admin['id'], "Schichtplan für Mitarbeiter #$targetId gespeichert");
+ flash('success', 'Schichtplan für ' . e($editAgentName ?? "#$targetId") . ' gespeichert.');
+ redirect('/staff/availability.php?edit=' . $targetId);
+ }
+}
+
+// ── Daten laden ───────────────────────────────────────────────────────────────
+$agents = $pdo->query(
+ "SELECT s.*, sa.status AS avail_status, sa.note AS avail_note, sa.updated_at AS avail_updated
+ FROM staff s
+ LEFT JOIN staff_availability sa ON sa.staff_id = s.id
+ WHERE s.is_active = 1
+ ORDER BY s.name"
+)->fetchAll();
+
+// Schichten für alle Mitarbeiter
+$allShifts = $pdo->query(
+ 'SELECT * FROM staff_shifts WHERE is_active = 1 ORDER BY staff_id, weekday, start_time'
+)->fetchAll();
+$shiftsByStaff = [];
+foreach ($allShifts as $sh) {
+ $shiftsByStaff[$sh['staff_id']][$sh['weekday']][] = $sh;
+}
+
+// Für den Edit-Modus
+$editStaffId = (int)($_GET['edit'] ?? 0);
+$editShifts = $editStaffId ? ($shiftsByStaff[$editStaffId] ?? []) : [];
+
+$pageTitle = 'Verfügbarkeit & Schichten';
+$activePage = 'availability';
+require __DIR__ . '/_header.php';
+
+$statusLabels = [
+ 'available' => ['Verfügbar', '#22c55e', '🟢'],
+ 'busy' => ['Beschäftigt', '#f59e0b', '🟡'],
+ 'break' => ['Pause', '#6366f1', '🟣'],
+ 'offline' => ['Offline', '#94a3b8', '⚫'],
+];
+$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
+?>
+
+
+
Verfügbarkeit & Schichten
+
+
+
+
+
+
+
+
+
Mein Status
+
+ [$sl, $sc, $se]): ?>
+
+ = $se ?> = $sl ?>
+
+
+
+
+
+
+
+
+
+
+
Team-Übersicht
+
+
+
+
+ = mb_strtoupper(mb_substr($ag['name'], 0, 1)) ?>
+
+
+
+
+ = e($ag['name']) ?>
+ 🌴
+
+
= $asl ?>
+
= e($ag['avail_note'] ?? '') ?>
+
+
+
⚙️
+
+
+
+
+
+
+
+
+
+
+
+
Schichtplan: = e($editAgent['name']) ?>
+
+ Definiere an welchen Tagen und zu welchen Zeiten dieser Mitarbeiter verfügbar ist.
+ Tickets werden außerhalb der Schicht nicht automatisch zugewiesen.
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/availability_status.php b/DeskHub-Webseite/staff/availability_status.php
new file mode 100644
index 00000000..b71b9b7b
--- /dev/null
+++ b/DeskHub-Webseite/staff/availability_status.php
@@ -0,0 +1,33 @@
+query(
+ "SELECT s.id, s.name, s.is_on_vacation,
+ COALESCE(sa.status, 'offline') AS status,
+ sa.note
+ FROM staff s
+ LEFT JOIN staff_availability sa ON sa.staff_id = s.id
+ WHERE s.is_active = 1
+ ORDER BY s.name"
+ )->fetchAll(PDO::FETCH_ASSOC);
+
+ echo json_encode(array_map(fn($r) => [
+ 'id' => (int)$r['id'],
+ 'status' => $r['status'],
+ 'note' => $r['note'] ?? '',
+ 'is_on_vacation' => (bool)$r['is_on_vacation'],
+ ], $rows));
+} catch (Throwable $e) {
+ echo json_encode([]);
+}
diff --git a/DeskHub-Webseite/staff/backup.php b/DeskHub-Webseite/staff/backup.php
new file mode 100644
index 00000000..de26495c
--- /dev/null
+++ b/DeskHub-Webseite/staff/backup.php
@@ -0,0 +1,350 @@
+prepare('SELECT value FROM backup_settings WHERE key_name=?');
+ $s->execute([$key]);
+ $r = $s->fetch();
+ return $r ? $r['value'] : null;
+}
+function bset_save(string $key, ?string $value): void {
+ global $pdo;
+ $sql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'
+ ? 'INSERT INTO backup_settings (key_name, value, updated_at) VALUES (?,?,?)
+ ON CONFLICT(key_name) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at'
+ : 'INSERT INTO backup_settings (key_name, value, updated_at) VALUES (?,?,?)
+ ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)';
+ $pdo->prepare($sql)->execute([$key, $value, db_now()]);
+}
+
+// ── POST-Handler ─────────────────────────────────────────────────────────────
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create_backup') {
+ $result = create_backup();
+ if ($result['ok']) {
+ // FTP-Upload wenn konfiguriert
+ $ftpHost = bget('ftp_host');
+ $ftpUser = bget('ftp_user');
+ $ftpPass = bget('ftp_pass');
+ $ftpPath = bget('ftp_path') ?: '/';
+ $ftpPort = (int)(bget('ftp_port') ?: 21);
+ $ftpTls = bget('ftp_tls') === '1';
+ $ftpMsg = '';
+ if ($ftpHost && $ftpUser && function_exists('ftp_connect')) {
+ $ftp = $ftpTls ? @ftp_ssl_connect($ftpHost, $ftpPort, 10) : @ftp_connect($ftpHost, $ftpPort, 10);
+ if ($ftp && @ftp_login($ftp, $ftpUser, $ftpPass)) {
+ ftp_pasv($ftp, true);
+ $remote = rtrim($ftpPath, '/') . '/' . $result['filename'];
+ $ftpMsg = ftp_put($ftp, $remote, $result['path'], FTP_BINARY)
+ ? ' – FTP-Upload erfolgreich.' : ' – FTP-Upload fehlgeschlagen.';
+ ftp_close($ftp);
+ } else {
+ $ftpMsg = ' – FTP-Verbindung fehlgeschlagen.';
+ }
+ }
+ // Lokale Rotation
+ $keep = (int)(bget('rotate_keep') ?: 0);
+ if ($keep > 0) {
+ $bdir = realpath(__DIR__ . '/../backups');
+ $files = array_merge(glob($bdir.'/backup_*.zip')?:[], glob($bdir.'/backup_*.tar.gz')?:[]);
+ usort($files, fn($a,$b) => filemtime($b)-filemtime($a));
+ foreach (array_slice($files, $keep) as $old) @unlink($old);
+ }
+ $verified = backup_verify_and_record($result['path']);
+ audit_log($staff['id'], "Manuelles Backup: {$result['filename']} ({$result['size']} MB)$ftpMsg");
+ flash($verified['ok'] ? 'success' : 'error', ($verified['ok'] ? "✅ Backup erstellt und geprüft: {$result['filename']} ({$result['size']} MB){$ftpMsg}"
+ : "Backup erstellt, aber NICHT wiederherstellbar: " . implode(' ', $verified['errors'])));
+ } else {
+ flash('error', 'Backup fehlgeschlagen: ' . $result['error']);
+ }
+ redirect('/staff/backup.php');
+ }
+
+ if ($action === 'verify_backup') {
+ $path = backup_path_for((string) ($_POST['filename'] ?? ''));
+ if (!$path) { flash('error', 'Backup nicht gefunden.'); redirect('/staff/backup.php'); }
+ $v = backup_verify_and_record($path);
+ $i = $v['info'];
+ if ($v['ok']) {
+ flash('success', '✅ ' . basename($path) . ' ist vollständig und wiederherstellbar (' . strtoupper((string) ($i['driver'] ?? '')) . '-Datenbank, ' . (int) ($i['tables'] ?? 0) . ' Tabellen'
+ . (isset($i['tickets']) ? ', ' . $i['tickets'] . ' Tickets' : '') . ', ' . (int) ($i['files'] ?? 0) . ' Dateien).' . ($v['warnings'] ? ' Hinweis: ' . implode(' ', $v['warnings']) : ''));
+ } else {
+ flash('error', basename($path) . ' ist NICHT wiederherstellbar: ' . implode(' ', $v['errors']));
+ }
+ redirect('/staff/backup.php');
+ }
+
+ if ($action === 'restore_backup') {
+ $path = backup_path_for((string) ($_POST['filename'] ?? ''));
+ if (!$path) { flash('error', 'Backup nicht gefunden.'); redirect('/staff/backup.php'); }
+ if (trim((string) ($_POST['confirm'] ?? '')) !== 'WIEDERHERSTELLEN') {
+ flash('error', 'Zur Bestätigung bitte WIEDERHERSTELLEN eintippen. Es wurde nichts verändert.');
+ redirect('/staff/backup.php');
+ }
+ $dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($_POST['dirs'] ?? [])));
+ $res = backup_restore($pdo, $path, ['database' => !empty($_POST['database']), 'dirs' => $dirs]);
+ if ($res['ok']) {
+ audit_log($staff['id'], 'Backup wiederhergestellt: ' . basename($path) . " ({$res['tables']} Tabellen, {$res['files']} Dateien, Sicherheits-Backup: " . ($res['safety'] ?? '–') . ')');
+ flash('success', '✅ Wiederhergestellt: ' . basename($path) . " ({$res['tables']} Tabellen, {$res['rows']} Datensätze, {$res['files']} Dateien). Der Stand von vorher steht im Sicherheits-Backup " . ($res['safety'] ?? '–') . '. ' . implode(' ', $res['notes'])
+ . ' Falls du abgemeldet wirst: Der Mitarbeiter-Stand entspricht jetzt dem Backup, bitte neu anmelden.');
+ } else {
+ flash('error', $res['error']);
+ }
+ redirect('/staff/backup.php');
+ }
+
+ if ($action === 'delete_backup') {
+ $file = basename($_POST['filename'] ?? '');
+ $bdir = realpath(__DIR__ . '/../backups');
+ $path = $bdir . '/' . $file;
+ if ($file && $bdir && file_exists($path)
+ && strpos(realpath($path), $bdir) === 0
+ && preg_match('/^backup_[\d\-_]+\.(zip|tar\.gz)$/', $file)) {
+ unlink($path);
+ audit_log($staff['id'], "Backup gelöscht: $file");
+ flash('success', "Backup $file gelöscht.");
+ } else {
+ flash('error', 'Backup nicht gefunden.');
+ }
+ redirect('/staff/backup.php');
+ }
+
+ if ($action === 'save_schedule') {
+ bset_save('schedule', $_POST['schedule'] ?? 'disabled');
+ bset_save('rotate_keep', (string)(int)($_POST['rotate_keep'] ?? 0));
+ audit_log($staff['id'], 'Backup-Zeitplan gespeichert');
+ flash('success', 'Zeitplan gespeichert.');
+ redirect('/staff/backup.php#zeitplan');
+ }
+
+ if ($action === 'save_ftp') {
+ bset_save('ftp_host', trim($_POST['ftp_host'] ?? '') ?: null);
+ bset_save('ftp_port', trim($_POST['ftp_port'] ?? '21'));
+ bset_save('ftp_user', trim($_POST['ftp_user'] ?? '') ?: null);
+ bset_save('ftp_tls', !empty($_POST['ftp_tls']) ? '1' : '0');
+ bset_save('ftp_path', trim($_POST['ftp_path'] ?? '/'));
+ bset_save('ftp_keep', (string)(int)($_POST['ftp_keep'] ?? 5));
+ if (!empty($_POST['ftp_pass'])) {
+ bset_save('ftp_pass', $_POST['ftp_pass']);
+ }
+ audit_log($staff['id'], 'FTP-Backup-Einstellungen gespeichert');
+ flash('success', 'FTP-Einstellungen gespeichert.');
+ redirect('/staff/backup.php#ftp');
+ }
+
+ if ($action === 'test_ftp') {
+ $host = bget('ftp_host'); $user = bget('ftp_user'); $pass = bget('ftp_pass');
+ $port = (int)(bget('ftp_port') ?: 21); $tls = bget('ftp_tls') === '1';
+ if (!$host || !$user) { flash('error', 'FTP nicht konfiguriert.'); }
+ elseif (!function_exists('ftp_connect')) { flash('error', 'PHP FTP-Erweiterung nicht verfügbar.'); }
+ else {
+ $ftp = $tls ? @ftp_ssl_connect($host, $port, 10) : @ftp_connect($host, $port, 10);
+ if ($ftp && @ftp_login($ftp, $user, $pass)) {
+ ftp_close($ftp);
+ flash('success', "✅ FTP-Verbindung zu $host erfolgreich.");
+ } else {
+ flash('error', "FTP-Verbindung zu $host fehlgeschlagen.");
+ }
+ }
+ redirect('/staff/backup.php#ftp');
+ }
+}
+
+// ── Daten laden ───────────────────────────────────────────────────────────────
+$backupDir = realpath(__DIR__ . '/../backups') ?: (dirname(__DIR__) . '/backups');
+$backupFiles = [];
+if (is_dir($backupDir)) {
+ foreach (array_merge(glob($backupDir.'/backup_*.zip')?:[], glob($backupDir.'/backup_*.tar.gz')?:[]) as $f) {
+ $backupFiles[] = ['name'=>basename($f),'size'=>round(filesize($f)/1024/1024,2),'date'=>filemtime($f)];
+ }
+ usort($backupFiles, fn($a,$b) => $b['date']-$a['date']);
+}
+$totalSize = array_sum(array_column($backupFiles, 'size'));
+$lastAutoRun = get_setting('cron_process_backups_last_run');
+$lastAutoBkp = bget('last_auto_backup');
+
+$pageTitle = 'Backup';
+$activePage = 'backup';
+require __DIR__ . '/_header.php';
+?>
+
+
+
💾 Backup
+
+
+
+
+
+
= count($backupFiles) ?>
Backups vorhanden
+
= $totalSize ?> MB
Gesamtgröße
+
= $backupFiles ? date('d.m.Y', $backupFiles[0]['date']) : '–' ?>
Letztes Backup
+
= $lastAutoBkp ? date('d.m.Y', strtotime($lastAutoBkp)) : '–' ?>
Letztes Auto-Backup
+
+
+
+
+
Vorhandene Backups
+
+
Letzte Prüfung: = e(date('d.m.Y H:i', strtotime($lastCheck['at']))) ?> – = e($lastCheck['file']) ?>:
+ = $lastCheck['ok'] ? 'wiederherstellbar' : 'nicht wiederherstellbar' ?>
+
+
+
Noch keine Backups vorhanden.
+
+
+ Dateiname Größe Erstellt
+
+
+
+ = e($b['name']) ?>
+ = $b['size'] ?> MB
+ = date('d.m.Y H:i', $b['date']) ?>
+
+ ⬇️ Download
+
+
+ ♻️ Wiederherstellen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⏰ Automatischer Zeitplan
+
+ Cron-Job einrichten: php = e(dirname(__DIR__)) ?>/cron/process_backups.php
+ Letzter Lauf: = format_dt($lastAutoRun) ?>
+
+
+
+
+
+
+
📡 FTP-Upload (optional)
+
Backups werden nach der Erstellung automatisch per FTP übertragen.
+
+
+
+
+
+
+
+
+
ℹ️ Hinweise
+
+ Backups enthalten alle Dateien inkl. Datenbank und Konfiguration
+ Backups werden automatisch vor jedem Update erstellt
+ Für automatische Backups den Cron einrichten:0 2 * * * php = e(dirname(__DIR__)) ?>/cron/process_backups.php
+ Backups liegen in backups/ und sind von außen nicht zugänglich
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/backup_download.php b/DeskHub-Webseite/staff/backup_download.php
new file mode 100644
index 00000000..84fd24fd
--- /dev/null
+++ b/DeskHub-Webseite/staff/backup_download.php
@@ -0,0 +1,31 @@
+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()]);
+ // Tickets schließen
+ $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]);
+ audit_log($staff['id'], "Sperrliste: $value hinzugefügt ($matchType)");
+ }
+ flash('success', "$value gesperrt.");
+ redirect('/staff/banlist.php');
+ }
+ }
+
+ if ($action === 'bulk_add') {
+ $raw = trim($_POST['bulk_values'] ?? '');
+ $reason = trim($_POST['bulk_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()]);
+ // Tickets schließen
+ $col = $type === '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(), $val]);
+ $added++;
+ }
+ audit_log($staff['id'], "Sperrliste Massen-Import: $added hinzugefügt, $skipped übersprungen");
+ flash('success', "$added Einträge gesperrt, $skipped übersprungen.");
+ redirect('/staff/banlist.php');
+ }
+
+ if ($action === 'delete') {
+ $id = (int)$_POST['id'];
+ $val = $pdo->prepare('SELECT value FROM banlist WHERE id=?');
+ $val->execute([$id]);
+ $v = $val->fetchColumn();
+ $pdo->prepare('DELETE FROM banlist WHERE id=?')->execute([$id]);
+ audit_log($staff['id'], "Sperrliste: $v entfernt");
+ flash('success', "$v entsperrt.");
+ redirect('/staff/banlist.php');
+ }
+}
+
+$entries = $pdo->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();
+
+$pageTitle = 'Sperrliste';
+$activePage = 'banlist';
+require __DIR__ . '/_header.php';
+?>
+
+
+
Sperrliste
+
+
+E-Mail-Adressen oder Domains die keine Tickets mehr erstellen dürfen. Beim Sperren werden alle offenen Tickets automatisch geschlossen.
+
+
+ = e($err) ?>
+
+
+
+
+
+
+
+
Gesperrte Einträge (= count($entries) ?>)
+
+ Typ Wert Grund Gesperrt am
+
+
+
+ = $b['match_type']==='domain'?'Domain':'E-Mail' ?>
+ = e($b['value']) ?>
+ = e($b['reason'] ?: '–') ?>
+ = format_dt($b['created_at']) ?>= $b['added_by_name'] ? ' '.e($b['added_by_name']) : '' ?>
+
+
+
+
+
+
+ Sperrliste ist leer.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mehrere sperren
+
Eine E-Mail oder Domain pro Zeile (oder kommagetrennt). Typ wird automatisch erkannt.
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/business_hours.php b/DeskHub-Webseite/staff/business_hours.php
new file mode 100644
index 00000000..f46bdf0a
--- /dev/null
+++ b/DeskHub-Webseite/staff/business_hours.php
@@ -0,0 +1,205 @@
+ 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 0 => 'Sonntag'];
+$weekdayOrder = [1, 2, 3, 4, 5, 6, 0];
+
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'save_hours') {
+ $enabled = !empty($_POST['business_hours_enabled']) ? '1' : '0';
+ set_setting('business_hours_enabled', $enabled);
+
+ $stmt = $pdo->prepare('UPDATE business_hours SET is_open=?, open_time=?, close_time=? WHERE weekday=?');
+ foreach ($weekdayOrder as $wd) {
+ $isOpen = !empty($_POST["open_{$wd}"]) ? 1 : 0;
+ $openTime = trim($_POST["from_{$wd}"] ?? '09:00') . ':00';
+ $closeTime = trim($_POST["to_{$wd}"] ?? '17:00') . ':00';
+ $stmt->execute([$isOpen, $openTime, $closeTime, $wd]);
+ }
+ audit_log($staff['id'], 'Geschäftszeiten aktualisiert');
+ flash('success', 'Geschäftszeiten gespeichert.');
+ redirect('/staff/business_hours.php');
+ }
+
+ if ($action === 'add_holiday') {
+ $date = trim($_POST['holiday_date'] ?? '');
+ $name = trim($_POST['holiday_name'] ?? '');
+ if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ $errors[] = 'Bitte ein gültiges Datum angeben.';
+ } elseif ($name === '') {
+ $errors[] = 'Bitte einen Namen für den Feiertag angeben.';
+ } else {
+ $pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)')->execute([$date, $name]);
+ audit_log($staff['id'], "Feiertag hinzugefügt: {$name} ({$date})");
+ flash('success', 'Feiertag hinzugefügt.');
+ redirect('/staff/business_hours.php?tab=holidays');
+ }
+ }
+
+ if ($action === 'delete_holiday') {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('DELETE FROM business_holidays WHERE id = ?')->execute([$id]);
+ redirect('/staff/business_hours.php?tab=holidays');
+ }
+
+ if ($action === 'import_holidays') {
+ $stateCode = $_POST['state_code'] ?? '';
+ $importYear = (int) ($_POST['import_year'] ?? 0);
+ if (!isset(GERMAN_STATE_NAMES[$stateCode])) {
+ $errors[] = 'Bitte ein gültiges Bundesland auswählen.';
+ } elseif ($importYear < 2000 || $importYear > 2100) {
+ $errors[] = 'Bitte ein gültiges Jahr auswählen.';
+ } else {
+ $newHolidays = german_public_holidays($stateCode, $importYear);
+ $existingDates = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
+ $insertStmt = $pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)');
+ $added = 0;
+ $skipped = 0;
+ foreach ($newHolidays as $h) {
+ if (isset($existingDates[$h['date']])) { $skipped++; continue; }
+ $insertStmt->execute([$h['date'], $h['name']]);
+ $existingDates[$h['date']] = true;
+ $added++;
+ }
+ $stateName = GERMAN_STATE_NAMES[$stateCode];
+ audit_log($staff['id'], "Feiertage {$importYear} für {$stateName} importiert ({$added} hinzugefügt, {$skipped} bereits vorhanden)");
+ flash('success', "{$added} Feiertag(e) für {$stateName} {$importYear} hinzugefügt" . ($skipped ? ", {$skipped} waren bereits vorhanden." : '.'));
+ redirect('/staff/business_hours.php?tab=holidays');
+ }
+ }
+}
+
+$hoursEnabled = get_setting('business_hours_enabled', '0') === '1';
+$hoursByWeekday = [];
+foreach ($pdo->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();
+
+$pageTitle = 'Geschäftszeiten';
+$activePage = 'business_hours';
+require __DIR__ . '/_header.php';
+?>
+
+Geschäftszeiten
+Legt fest, innerhalb welcher Zeiten die SLA-Fristen (Reaktion/Lösung) laufen. Bei deaktivierten Geschäftszeiten gelten die Fristen wie bisher rund um die Uhr. Achtung: Wenige Stunden pro Tag (z. B. nur abends) lassen die Fälligkeit in Prioritäten & SLA-Zeiten entsprechend weiter in die Zukunft rücken – eine 72-Std-Lösungsfrist kann bei 3 offenen Stunden/Tag mehrere Wochen dauern.
+
+
+ = e($err) ?>
+
+
+
+
+ 'Wochenzeiten', 'holidays' => 'Feiertage (' . count($holidays) . ')']); ?>
+
+
+
Öffnungszeiten je Wochentag
+
+ = csrf_field() ?>
+
+
+
+ >
+ Geschäftszeiten für SLA-Berechnung verwenden
+
+
+
+
+
+ Änderungen speichern
+
+
+
+
+
+
Feiertage
+
An diesen Tagen laufen keine SLA-Fristen, unabhängig vom Wochentag.
+
+
+
+ = csrf_field() ?>
+
+
+ Bundesland
+
+ – auswählen –
+ $name): ?>
+ = e($name) ?>
+
+
+
+
+ Jahr
+
+
+ = $yr ?>
+
+
+
+ Feiertage automatisch übernehmen
+
+
Trägt die gesetzlichen Feiertage des gewählten Bundeslandes für das gewählte Jahr ein (inkl. beweglicher Feiertage wie Ostern). Bereits vorhandene Daten werden übersprungen, nichts wird doppelt angelegt.
+
+
+
+ Datum Name
+
+
+
+ = e(date('d.m.Y', strtotime($h['holiday_date']))) ?>
+ = e($h['name']) ?>
+
+
+ = csrf_field() ?>
+
+
+ ✕
+
+
+
+
+
+ Noch keine Feiertage eingetragen.
+
+
+
+
+ = csrf_field() ?>
+
+ Datum
+
+ Name
+
+
+ Hinzufügen
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/canned_responses.php b/DeskHub-Webseite/staff/canned_responses.php
new file mode 100644
index 00000000..35335bbd
--- /dev/null
+++ b/DeskHub-Webseite/staff/canned_responses.php
@@ -0,0 +1,171 @@
+query('SELECT * FROM departments ORDER BY name')->fetchAll();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $title = trim($_POST['title'] ?? '');
+ $body = sanitize_staff_html(trim($_POST['body'] ?? ''));
+ $deptId = $_POST['department_id'] !== '' ? (int) $_POST['department_id'] : null;
+
+ if ($title === '') $errors[] = 'Bitte einen Titel angeben.';
+ if (trim(strip_tags($body)) === '') $errors[] = 'Bitte einen Text angeben.';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $pdo->prepare('INSERT INTO canned_responses (title, body, department_id, created_at) VALUES (?,?,?,?)')
+ ->execute([$title, $body, $deptId, db_now()]);
+ audit_log($admin['id'], "Textbaustein erstellt: {$title}");
+ flash('success', 'Textbaustein erstellt.');
+ } else {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('UPDATE canned_responses SET title=?, body=?, department_id=? WHERE id=?')
+ ->execute([$title, $body, $deptId, $id]);
+ audit_log($admin['id'], "Textbaustein aktualisiert: {$title}");
+ flash('success', 'Textbaustein aktualisiert.');
+ }
+ redirect('/staff/canned_responses.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('DELETE FROM canned_responses WHERE id = ?')->execute([$id]);
+ audit_log($admin['id'], "Textbaustein gelöscht: #{$id}");
+ flash('success', 'Textbaustein gelöscht.');
+ redirect('/staff/canned_responses.php');
+ }
+}
+
+$responses = $pdo->query(
+ 'SELECT cr.*, d.name AS department_name FROM canned_responses cr
+ LEFT JOIN departments d ON d.id = cr.department_id
+ ORDER BY cr.title'
+)->fetchAll();
+
+$editId = $_GET['edit'] ?? null;
+$editResponse = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM canned_responses WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editResponse = $stmt->fetch();
+}
+$showForm = isset($_GET['new']) || $editResponse;
+
+$pageTitle = 'Vorlagen';
+$activePage = 'canned';
+require __DIR__ . '/_header.php';
+?>
+
+Vorlagen / Textbausteine
+Mitarbeiter können diese Textbausteine beim Beantworten eines Tickets per Klick in die Antwort einfügen.
+
+
+ = e($err) ?>
+
+
+
+
+
+
+
+
= $editResponse ? 'Textbaustein bearbeiten' : 'Neuer Textbaustein' ?>
+
+ = csrf_field() ?>
+
+
+ Titel
+
+ Abteilung (optional)
+
+ Alle Abteilungen
+
+ >= e($d['name']) ?>
+
+
+ Text
+
+
Verfügbare Variablen — klicken zum Einfügen:
+
+
+
+ = e($var) ?>
+
+
+
+
+ = e($editResponse['body'] ?? '') ?>
+
+
+
+
+
+
+
+
+ Titel Abteilung Text (Vorschau)
+
+
+
+ = e($r['title']) ?>
+ = e($r['department_name'] ?? 'Alle') ?>
+ $0', e($preview));
+ ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/catalog_preview.php b/DeskHub-Webseite/staff/catalog_preview.php
new file mode 100644
index 00000000..05a9cb55
--- /dev/null
+++ b/DeskHub-Webseite/staff/catalog_preview.php
@@ -0,0 +1,13 @@
+query('SELECT id, name, is_active FROM staff ORDER BY name')->fetchAll();
+
+// Ungelesene Nachrichten je Absender
+$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 $r) {
+ $unreadByStaff[$r['from_staff_id']] = (int)$r['cnt'];
+}
+
+$conversation = [];
+$withStaff = null;
+
+if ($withId) {
+ $s = $pdo->prepare('SELECT * FROM staff WHERE id=?');
+ $s->execute([$withId]);
+ $withStaff = $s->fetch();
+
+ // Als gelesen markieren
+ $pdo->prepare('UPDATE staff_chat_messages SET is_read=1 WHERE from_staff_id=? AND to_staff_id=?')
+ ->execute([$withId, $staff['id']]);
+
+ // Konversation laden
+ $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 200"
+ );
+ $cStmt->execute([$staff['id'], $withId, $withId, $staff['id']]);
+ $conversation = $cStmt->fetchAll();
+}
+
+// Nachricht senden (AJAX oder normal)
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $toId = (int) ($_POST['to_id'] ?? 0);
+ $msg = trim($_POST['message'] ?? '');
+ if ($toId && $msg !== '') {
+ $pdo->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, created_at) VALUES (?,?,?,?)')
+ ->execute([$staff['id'], $toId, $msg, db_now()]);
+ }
+ if (!empty($_SERVER['HTTP_X_REQUESTED_WITH'])) {
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => true]);
+ exit;
+ }
+ redirect('/staff/chat.php?with=' . $toId);
+}
+
+// AJAX: neue Nachrichten pollen
+if (($_GET['action'] ?? '') === 'poll' && $withId) {
+ header('Content-Type: application/json');
+ $since = $_GET['since'] ?? '1970-01-01 00:00:00';
+ $pStmt = $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=?))
+ AND m.created_at > ?
+ ORDER BY m.created_at ASC"
+ );
+ $pStmt->execute([$staff['id'], $withId, $withId, $staff['id'], $since]);
+ $pdo->prepare('UPDATE staff_chat_messages SET is_read=1 WHERE from_staff_id=? AND to_staff_id=?')
+ ->execute([$withId, $staff['id']]);
+ echo json_encode($pStmt->fetchAll());
+ exit;
+}
+
+$pageTitle = 'Interner Chat';
+$activePage = 'chat';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mitarbeiter auswählen
+
Klicke rechts auf einen Mitarbeiter
+
+
+
+
+
+
+
= e($ini2) ?>
+
+
= e($withStaff['name']) ?>
+
= e($withStaff['email']) ?>
+
+
+
+
+
+
+
Noch keine Nachrichten – schreib etwas!
+
+
+
= date('d.m.Y', strtotime($date)) ?>
+
+
+
+
= e($ini) ?>
+
+
+
= e($msg['from_name']) ?>
+
= nl2br(e($msg['message'])) ?>
+
= date('H:i', strtotime($msg['created_at'])) ?>
+
+
+
+
+
+
+
+
+
+ = csrf_field() ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/custom_fields.php b/DeskHub-Webseite/staff/custom_fields.php
new file mode 100644
index 00000000..fe3d449d
--- /dev/null
+++ b/DeskHub-Webseite/staff/custom_fields.php
@@ -0,0 +1,172 @@
+ 'Einzeiliger Text',
+ 'textarea' => 'Mehrzeiliger Text',
+ 'select' => 'Dropdown-Auswahl',
+ 'checkbox' => 'Checkbox (Ja/Nein)',
+ 'number' => 'Zahl',
+ 'date' => 'Datum',
+];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $label = trim($_POST['label'] ?? '');
+ $type = array_key_exists($_POST['field_type'] ?? '', $fieldTypes) ? $_POST['field_type'] : 'text';
+ $optionsRaw = trim($_POST['options'] ?? '');
+ $helpTopicId = ($_POST['help_topic_id'] ?? '') !== '' ? (int) $_POST['help_topic_id'] : null;
+ $isRequired = !empty($_POST['is_required']) ? 1 : 0;
+ $isActive = !empty($_POST['is_active']) ? 1 : 0;
+ $sort = (int) ($_POST['sort_order'] ?? 0);
+
+ if ($label === '') $errors[] = 'Bitte eine Bezeichnung angeben.';
+ if ($type === 'select' && $optionsRaw === '') $errors[] = 'Bitte mindestens eine Option angeben (eine pro Zeile).';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $pdo->prepare('INSERT INTO custom_fields (label, field_type, options, help_topic_id, is_required, is_active, sort_order, created_at) VALUES (?,?,?,?,?,?,?,?)')
+ ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, db_now()]);
+ audit_log($admin['id'], "Ticket-Feld erstellt: {$label}");
+ flash('success', 'Feld erstellt.');
+ } else {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('UPDATE custom_fields SET label=?, field_type=?, options=?, help_topic_id=?, is_required=?, is_active=?, sort_order=? WHERE id=?')
+ ->execute([$label, $type, $optionsRaw ?: null, $helpTopicId, $isRequired, $isActive, $sort, $id]);
+ audit_log($admin['id'], "Ticket-Feld aktualisiert: {$label}");
+ flash('success', 'Feld aktualisiert.');
+ }
+ redirect('/staff/custom_fields.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $labelStmt = $pdo->prepare('SELECT label FROM custom_fields WHERE id = ?');
+ $labelStmt->execute([$id]);
+ $delLabel = $labelStmt->fetchColumn();
+ $pdo->prepare('DELETE FROM custom_fields WHERE id = ?')->execute([$id]);
+ audit_log($admin['id'], "Ticket-Feld gelöscht: {$delLabel}");
+ flash('success', 'Feld gelöscht (bereits erfasste Werte bleiben in bestehenden Tickets sichtbar).');
+ redirect('/staff/custom_fields.php');
+ }
+}
+
+$fields = $pdo->query(
+ 'SELECT cf.*, ht.name AS help_topic_name FROM custom_fields cf
+ LEFT JOIN help_topics ht ON ht.id = cf.help_topic_id
+ ORDER BY cf.sort_order, cf.label'
+)->fetchAll();
+$helpTopics = $pdo->query('SELECT * FROM help_topics ORDER BY name')->fetchAll();
+
+$editId = $_GET['edit'] ?? null;
+$editField = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM custom_fields WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editField = $stmt->fetch();
+}
+
+$pageTitle = 'Ticket-Felder';
+$activePage = 'custom_fields';
+require __DIR__ . '/_header.php';
+?>
+
+Individuelle Ticket-Felder
+Diese Felder erscheinen zusätzlich zu Name, E-Mail, Betreff und Beschreibung auf dem Formular „Neues Ticket erstellen“. Die eingegebenen Werte werden im Ticket angezeigt.
+
+
+ = e($err) ?>
+
+
+
+
+
+
+ Bezeichnung Typ Hilfe-Thema Pflichtfeld Aktiv
+
+
+
+ = e($f['label']) ?>
+ = e($fieldTypes[$f['field_type']] ?? $f['field_type']) ?>
+ = $f['help_topic_name'] ? e($f['help_topic_name']) : 'Alle Themen' ?>
+ = $f['is_required'] ? '✓' : '–' ?>
+ = $f['is_active'] ? '✓' : '–' ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+ Noch keine individuellen Felder angelegt.
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/custom_lists.php b/DeskHub-Webseite/staff/custom_lists.php
new file mode 100644
index 00000000..e76b1c8e
--- /dev/null
+++ b/DeskHub-Webseite/staff/custom_lists.php
@@ -0,0 +1,160 @@
+prepare('SELECT COUNT(*) FROM custom_lists WHERE LOWER(name) = LOWER(?) AND id <> ?');
+ $dup->execute([$name, $id]);
+ if ($dup->fetchColumn() > 0) $errors[] = 'Eine Liste mit diesem Namen existiert bereits.';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $pdo->prepare('INSERT INTO custom_lists (name, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?)')->execute([$name, $sort, $notes ?: null, db_now(), db_now()]);
+ $id = (int) $pdo->lastInsertId();
+ audit_log($staff['id'], "Liste erstellt: {$name}");
+ flash('success', 'Liste erstellt.');
+ } else {
+ $pdo->prepare('UPDATE custom_lists SET name=?, sort_order=?, notes=?, updated_at=? WHERE id=?')->execute([$name, $sort, $notes ?: null, db_now(), $id]);
+ audit_log($staff['id'], "Liste aktualisiert: {$name}");
+ flash('success', 'Änderungen gespeichert.');
+ }
+ // Einträge abgleichen: Bestehende behalten (und umsortieren), neue anlegen, entfernte löschen
+ $existing = $pdo->prepare('SELECT id, value FROM custom_list_items WHERE list_id = ?');
+ $existing->execute([$id]);
+ $byValue = array_column($existing->fetchAll(), 'id', 'value');
+ $upd = $pdo->prepare('UPDATE custom_list_items SET sort_order = ? WHERE id = ?');
+ $ins = $pdo->prepare('INSERT INTO custom_list_items (list_id, value, sort_order) VALUES (?,?,?)');
+ foreach ($values as $i => $val) {
+ if (isset($byValue[$val])) { $upd->execute([($i + 1) * 10, $byValue[$val]]); unset($byValue[$val]); }
+ else $ins->execute([$id, $val, ($i + 1) * 10]);
+ }
+ $del = $pdo->prepare('DELETE FROM custom_list_items WHERE id = ?');
+ foreach ($byValue as $itemId) $del->execute([$itemId]);
+ redirect('/staff/custom_lists.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('DELETE FROM custom_list_items WHERE list_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM custom_lists WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "Liste gelöscht: #{$id}");
+ flash('success', 'Liste gelöscht.');
+ redirect('/staff/custom_lists.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$lists = $pdo->query('SELECT l.*, (SELECT COUNT(*) FROM custom_list_items i WHERE i.list_id = l.id) AS item_count FROM custom_lists l ORDER BY l.sort_order, l.name')->fetchAll();
+$editList = null; $editItems = [];
+if ($editId) {
+ $es = $pdo->prepare('SELECT * FROM custom_lists WHERE id = ?'); $es->execute([$editId]);
+ $editList = $es->fetch() ?: null;
+ $ei = $pdo->prepare('SELECT value FROM custom_list_items WHERE list_id = ? ORDER BY sort_order, value'); $ei->execute([$editId]);
+ $editItems = $ei->fetchAll(PDO::FETCH_COLUMN);
+}
+
+$pageTitle = 'Benutzerdefinierte Listen'; $activePage = 'custom_lists';
+require __DIR__ . '/_header.php';
+?>
+
+= e($err) ?>
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editList[$col] ?? $default);
+?>
+
+ = $editList ? 'Liste aktualisieren' : 'Neue Liste' ?>
+ — = e($editList['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+ 'Liste', 'items' => 'Einträge']); ?>
+
+
+
Listeninformationen
+
Name *
+
+
Sortierung
+
+
Administratornotizen – Interne Notizen, sichtbar für alle Administratoren
+
= e($v('notes', 'notes')) ?>
+
Wiederverwendbare Auswahllisten für Ticket-Felder: Wähle in einem Ticket-Feld den Typ „Liste“ und verknüpfe diese Liste.
+
+
+
+
Einträge – ein Wert pro Zeile, die Reihenfolge entspricht der Anzeige
+
= e($_POST ? (string) ($_POST['items'] ?? '') : implode("\n", $editItems)) ?>
+
Entfernte Zeilen werden gelöscht, bereits in Tickets gespeicherte Werte bleiben dort unverändert sichtbar.
+
+
+
+
= $editList ? 'Änderungen speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+Wiederverwendbare Auswahllisten für Ticket-Felder. Wähle in einem Ticket-Feld den Typ „Liste“ und verknüpfe eine dieser Listen.
+
+
+
+ Name Einträge Erstellt Zuletzt aktualisiert
+
+
+
+ = e($l['name']) ?>
+ = (int) $l['item_count'] ?>
+ = e(ui_date($l['created_at'] ?? null)) ?>
+ = e(ui_date($l['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+ Noch keine Listen angelegt.
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/customer_profile.php b/DeskHub-Webseite/staff/customer_profile.php
new file mode 100644
index 00000000..47b94bfd
--- /dev/null
+++ b/DeskHub-Webseite/staff/customer_profile.php
@@ -0,0 +1,225 @@
+ 0) {
+ $stmt = $pdo->prepare('SELECT u.*, o.name AS org_name FROM users u LEFT JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?');
+ $stmt->execute([$userId]);
+ $customer = $stmt->fetch();
+} elseif ($guestEmail !== '') {
+ // Gast-Anfrager (kein Konto, nur über Tickets bekannt) – keine Zeile in "users",
+ // daher hier aus den Ticket-Daten zusammengesetzt statt per user_id nachgeschlagen.
+ $stmt = $pdo->prepare(
+ "SELECT requester_email AS email, MAX(requester_name) AS name, MIN(created_at) AS created_at,
+ NULL AS phone, NULL AS org_name
+ FROM tickets WHERE requester_email = ? AND user_id IS NULL GROUP BY requester_email"
+ );
+ $stmt->execute([$guestEmail]);
+ $customer = $stmt->fetch();
+ $isGuest = (bool) $customer;
+} else {
+ $customer = false;
+}
+
+if (!$customer) {
+ flash('error', 'Kunde nicht gefunden.');
+ redirect('/staff/customers.php');
+}
+
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ // Notizen und Kontaktdaten hängen an einem echten Kundenkonto (users-Tabelle) –
+ // für Gast-Anfrager ohne Konto gibt es das nicht, siehe Hinweis oben in der Seite.
+ if ($isGuest && in_array($action, ['add_note', 'delete_note', 'save_customer'], true)) {
+ flash('error', 'Für Gast-Anfrager ohne Konto nicht verfügbar.');
+ redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail));
+ }
+
+ if ($action === 'add_note') {
+ $note = trim($_POST['note'] ?? '');
+ if ($note !== '') {
+ $pdo->prepare('INSERT INTO customer_notes (user_id, staff_id, note, created_at) VALUES (?,?,?,?)')
+ ->execute([$userId, $staff['id'], $note, db_now()]);
+ flash('success', 'Notiz gespeichert.');
+ }
+ redirect('/staff/customer_profile.php?id=' . $userId);
+ }
+
+ if ($action === 'delete_note') {
+ $noteId = (int) ($_POST['note_id'] ?? 0);
+ $pdo->prepare('DELETE FROM customer_notes WHERE id=? AND user_id=?')->execute([$noteId, $userId]);
+ redirect('/staff/customer_profile.php?id=' . $userId);
+ }
+
+ if ($action === 'save_customer') {
+ $name = trim($_POST['name'] ?? '');
+ $phone = trim($_POST['phone'] ?? '');
+ if ($name !== '') {
+ $pdo->prepare('UPDATE users SET name=?, phone=? WHERE id=?')->execute([$name, $phone ?: null, $userId]);
+ flash('success', 'Kundendaten gespeichert.');
+ }
+ redirect('/staff/customer_profile.php?id=' . $userId);
+ }
+}
+
+// Alle Tickets dieses Kunden (Mitarbeiter sehen nur Tickets ihrer Abteilungen)
+[$deptSql, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
+$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 = ?) {$deptSql}
+ ORDER BY t.created_at DESC LIMIT 100"
+);
+$ticketStmt->execute([$customer['email'], $userId, ...$deptParams]);
+$tickets = $ticketStmt->fetchAll();
+
+// Notizen
+$noteStmt = $pdo->prepare(
+ "SELECT cn.*, COALESCE(s.name, 'Gelöschter Mitarbeiter') AS staff_name
+ FROM customer_notes cn
+ LEFT JOIN staff s ON s.id = cn.staff_id
+ WHERE cn.user_id = ? ORDER BY cn.created_at DESC"
+);
+$noteStmt->execute([$userId]);
+$notes = $noteStmt->fetchAll();
+
+// Statistiken
+$totalTickets = count($tickets);
+$openTickets = count(array_filter($tickets, fn($t) => !in_array($t['status'], ['resolved','closed'])));
+$closedTickets = count(array_filter($tickets, fn($t) => in_array($t['status'], ['resolved','closed'])));
+
+$pageTitle = 'Kundenprofil: ' . $customer['name'];
+$activePage = 'customers';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
= e($customer['name']) ?> Gast
+ = e($customer['email']) ?>
+
+
← Zurück
+
+
+ Gast-Anfrager ohne eigenes Kundenkonto – Notizen und Kontaktdaten-Bearbeitung sind daher nicht verfügbar.
+
+
+
+
+
+
+
+
+
+
Tickets (= $totalTickets ?>)
+
+
Keine Tickets vorhanden.
+
+
+
+
+
+
+
+
+
Interne Notizen
+
+ = csrf_field() ?>
+
+
+ Notiz speichern
+
+
+
Noch keine Notizen.
+
+
+
+
+
= e($n['staff_name']) ?> · = format_dt($n['created_at']) ?>
+
= e($n['note']) ?>
+
+
+ = csrf_field() ?>
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= $closedTickets ?>
Gelöst
+
= format_dt($customer['created_at'], 'd.m.Y') ?>
Seit
+
+
+
+
+
Kontaktdaten
+
+
Name
+
= e($customer['name']) ?>
+
E-Mail
+
= e($customer['email']) ?>
+
Ohne Konto nicht bearbeitbar.
+
+
+ = csrf_field() ?>
+
+ Name
+
+ Telefon
+
+ E-Mail
+ = e($customer['email']) ?>
+
+ Organisation
+ = e($customer['org_name']) ?>
+
+ Speichern
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/customers.php b/DeskHub-Webseite/staff/customers.php
new file mode 100644
index 00000000..9ae0440a
--- /dev/null
+++ b/DeskHub-Webseite/staff/customers.php
@@ -0,0 +1,215 @@
+query('SELECT * FROM organizations ORDER BY name')->fetchAll();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'assign_org' && $staff['role'] === 'admin') {
+ $userId = (int) $_POST['user_id'];
+ $orgId = $_POST['organization_id'] !== '' ? (int) $_POST['organization_id'] : null;
+ $pdo->prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]);
+ audit_log($staff['id'], "Organisation zugeordnet für Kunde #{$userId}");
+ flash('success', 'Organisation zugeordnet.');
+ redirect('/staff/customers.php' . ($_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : ''));
+ }
+
+ if ($action === 'ban_email') {
+ $email = trim($_POST['email'] ?? '');
+ $matchType = ($_POST['match_type'] ?? 'email') === 'domain' ? 'domain' : 'email';
+ $value = $matchType === 'domain' ? (substr(strrchr($email, '@'), 1) ?: $email) : $email;
+ if ($value !== '') {
+ // Nur einfügen wenn noch nicht vorhanden
+ $exists = $pdo->prepare('SELECT COUNT(*) FROM banlist WHERE match_type=? AND LOWER(value)=?');
+ $exists->execute([$matchType, strtolower($value)]);
+ if (!(int)$exists->fetchColumn()) {
+ $pdo->prepare('INSERT INTO banlist (match_type, value, reason, created_by, created_at) VALUES (?,?,?,?,?)')
+ ->execute([$matchType, strtolower($value), 'Gesperrt aus Kundenverwaltung', $staff['id'], db_now()]);
+ audit_log($staff['id'], "Kunde gesperrt: {$value} ({$matchType})");
+ flash('success', '"' . $value . '" wurde zur Sperrliste hinzugefuegt.');
+ } else {
+ flash('info', '"' . $value . '" ist bereits auf der Sperrliste.');
+ }
+ }
+ redirect('/staff/customers.php' . ($_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : ''));
+ }
+
+ if ($action === 'unban_email') {
+ $email = strtolower(trim($_POST['email'] ?? ''));
+ $domain = substr(strrchr($email, '@'), 1) ?: '';
+ $pdo->prepare('DELETE FROM banlist WHERE (match_type=\'email\' AND LOWER(value)=?) OR (match_type=\'domain\' AND LOWER(value)=?)')
+ ->execute([$email, $domain]);
+ audit_log($staff['id'], "Kundensperrung aufgehoben: {$email}");
+ flash('success', "Sperrung aufgehoben fuer: {$email}");
+ }
+}
+
+$search = trim($_GET['q'] ?? '');
+$like = '%' . $search . '%';
+
+// Registrierte Kunden
+$regSql = "SELECT u.*, o.name AS organization_name,
+ (SELECT COUNT(*) FROM tickets t WHERE t.user_id = u.id) AS ticket_count,
+ (SELECT MAX(t2.requester_ip) FROM tickets t2 WHERE t2.user_id = u.id AND t2.requester_ip IS NOT NULL) AS last_ip
+ FROM users u LEFT JOIN organizations o ON o.id = u.organization_id";
+$regParams = [];
+if ($search !== '') {
+ $regSql .= ' WHERE u.name LIKE ? OR u.email LIKE ?';
+ $regParams = [$like, $like];
+}
+$regSql .= ' ORDER BY u.name LIMIT 200';
+$regStmt = $pdo->prepare($regSql);
+$regStmt->execute($regParams);
+$registered = array_map(fn($c) => array_merge($c, ['is_guest' => false]), $regStmt->fetchAll());
+
+// Gast-Kunden
+$guestSql = "SELECT requester_email AS email, MAX(requester_name) AS name,
+ COUNT(*) AS ticket_count, MIN(created_at) AS created_at,
+ MAX(requester_ip) AS last_ip
+ 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 200';
+$guestStmt = $pdo->prepare($guestSql);
+$guestStmt->execute($guestParams);
+$guests = array_map(fn($g) => array_merge($g, [
+ 'id' => null, 'organization_id' => null, 'organization_name' => null, 'is_guest' => true,
+]), $guestStmt->fetchAll());
+
+$customers = array_slice(
+ usort_return(array_merge($registered, $guests), fn($a, $b) => strcasecmp($a['name'] ?? '', $b['name'] ?? '')),
+ 0, 200
+);
+
+// Alle aktuell gesperrten E-Mails/Domains aus der Sperrliste vorausberechnen
+$bannedEmails = $pdo->query("SELECT LOWER(value) FROM banlist WHERE match_type='email'")->fetchAll(PDO::FETCH_COLUMN);
+$bannedDomains = $pdo->query("SELECT LOWER(value) FROM banlist WHERE match_type='domain'")->fetchAll(PDO::FETCH_COLUMN);
+$bannedEmailSet = array_flip($bannedEmails);
+$bannedDomainSet = array_flip($bannedDomains);
+
+function is_customer_banned(string $email, array $bannedEmailSet, array $bannedDomainSet): bool
+{
+ $email = strtolower($email);
+ $domain = substr(strrchr($email, '@'), 1) ?: '';
+ return isset($bannedEmailSet[$email]) || isset($bannedDomainSet[$domain]);
+}
+
+function usort_return(array $arr, callable $fn): array { usort($arr, $fn); return $arr; }
+
+$pageTitle = 'Kunden';
+$activePage = 'customers';
+require __DIR__ . '/_header.php';
+?>
+
+Kunden
+Registrierte Kundenkonten und Gast-Anfrager (ohne Konto) aus allen Tickets.
+
+
+
+ Suche
+
+
+ Suchen
+
+
+
+
+
+
+
+
+
+ Name
+ E-Mail
+ IP-Adresse
+ Typ
+ Organisation
+ Tickets
+ Seit
+ Status
+
+
+
+
+
+
+ = e($c['name']) ?>
+ = e($c['email']) ?>
+
+ = $c['last_ip'] ? e($c['last_ip']) : '– ' ?>
+
+
+ = $c['is_guest']
+ ? 'Gast '
+ : 'Konto ' ?>
+
+
+
+ – (kein Konto)
+
+
+ = csrf_field() ?>
+
+
+
+ – keine –
+
+ >= e($o['name']) ?>
+
+
+
+
+
+
+ 0): ?>
+ = (int)$c['ticket_count'] ?>
+
+ 0
+
+
+ = format_dt($c['created_at']) ?>
+
+
+ Gesperrt
+
+ Aktiv
+
+
+
+
+
+ = csrf_field() ?>
+
+
+ ✓ Entsperren
+
+
+ werden abgewiesen.');">
+ = csrf_field() ?>
+
+
+
+ 🚫 Sperren
+
+
+
+
+
+
+ Keine Kunden gefunden.
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/dashboard.php b/DeskHub-Webseite/staff/dashboard.php
new file mode 100644
index 00000000..05ae7920
--- /dev/null
+++ b/DeskHub-Webseite/staff/dashboard.php
@@ -0,0 +1,11 @@
+prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed')" . $deptFilter);
+$open->execute($deptParams); $open = (int)$open->fetchColumn();
+
+$mine = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id=? AND status NOT IN ('resolved','closed')" . $deptFilter);
+$mine->execute(array_merge([$staff['id']], $deptParams)); $mine = (int)$mine->fetchColumn();
+
+$unassigned = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')" . $deptFilter);
+$unassigned->execute($deptParams); $unassigned = (int)$unassigned->fetchColumn();
+
+$overdue = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?" . $deptFilter);
+$overdue->execute(array_merge([$now], $deptParams)); $overdue = (int)$overdue->fetchColumn();
+
+$unanswered = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'" . $deptFilter);
+$unanswered->execute($deptParams); $unanswered = (int)$unanswered->fetchColumn();
+
+$mentions = $pdo->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
+$mentions->execute([$staff['id']]); $mentions = (int)$mentions->fetchColumn();
+
+echo json_encode(['open'=>$open,'mine'=>$mine,'unassigned'=>$unassigned,'overdue'=>$overdue,'unanswered'=>$unanswered,'mentions'=>$mentions]);
diff --git a/DeskHub-Webseite/staff/departments.php b/DeskHub-Webseite/staff/departments.php
new file mode 100644
index 00000000..822168d3
--- /dev/null
+++ b/DeskHub-Webseite/staff/departments.php
@@ -0,0 +1,244 @@
+query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
+ try {
+ department_save($pdo, $id, [
+ 'name' => $_POST['name'] ?? '', 'email' => $_POST['email'] ?? '', 'is_default' => !empty($_POST['is_default']),
+ 'is_active' => ($_POST['status'] ?? 'active') === 'active', 'manager_id' => $_POST['manager_id'] ?? null,
+ 'sla_plan_id' => $_POST['sla_plan_id'] ?? null, 'notes' => $_POST['notes'] ?? '', 'signature' => $_POST['signature'] ?? '',
+ ]);
+ audit_log($staff['id'], ($action === 'create' ? 'Abteilung erstellt: ' : 'Abteilung aktualisiert: ') . trim($_POST['name'] ?? ''));
+ flash('success', $action === 'create' ? 'Abteilung erstellt.' : 'Änderungen gespeichert.');
+ redirect('/staff/departments.php');
+ } catch (AdminModelException $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if ($action === 'delete') {
+ try {
+ $deleted = department_delete($pdo, (int) $_POST['id']);
+ audit_log($staff['id'], "Abteilung gelöscht: {$deleted}");
+ flash('success', 'Abteilung gelöscht.');
+ } catch (AdminModelException $e) {
+ flash('error', $e->getMessage());
+ }
+ redirect('/staff/departments.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$editDept = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editDept = $stmt->fetch() ?: null;
+}
+
+// Mitglieder = Hauptabteilung ODER zusätzlich zugewiesene Abteilung
+$memberStmt = $pdo->prepare(
+ 'SELECT s.id, s.name, s.email, s.role, s.is_active, (s.department_id = ?) AS is_primary
+ FROM staff s
+ WHERE s.department_id = ? OR s.id IN (SELECT staff_id FROM staff_department WHERE department_id = ?)
+ ORDER BY s.name'
+);
+
+$departments = $pdo->query(
+ 'SELECT d.*, m.name AS manager_name,
+ (SELECT COUNT(*) FROM staff s WHERE s.department_id = d.id OR s.id IN (SELECT staff_id FROM staff_department sd WHERE sd.department_id = d.id)) AS member_count
+ 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();
+$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
+
+$pageTitle = 'Abteilungen';
+$activePage = 'departments';
+require __DIR__ . '/_header.php';
+?>
+
+
+ = e($err) ?>
+
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editDept[$col] ?? $default);
+ $curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editDept['is_active'] ?? 1) ? 'active' : 'inactive');
+ $curEmail = strtolower((string) $v('email', 'email'));
+ $curManager = (string) $v('manager_id', 'manager_id');
+ $isDefaultChecked = $_POST ? !empty($_POST['is_default']) : !empty($editDept['is_default']);
+ $members = [];
+ if ($editDept) {
+ $did = (int) $editDept['id'];
+ $memberStmt->execute([$did, $did, $did]);
+ $members = $memberStmt->fetchAll();
+ }
+ $seen = false;
+?>
+
+ = $editDept ? 'Abteilung aktualisieren' : 'Neue Abteilung' ?>
+ — = e($editDept['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+
+ Abteilung
+ Mitglieder= $editDept ? ' (' . count($members) . ')' : '' ?>
+
+
+
+
Abteilungsinformationen
+
Name *
+
+
+
Status *
+
+ > Aktiv
+ > Deaktiviert
+
+
+
Abteilungsleiter
+
+ — Keine(r) —
+
+ >= e($s['name']) ?>
+
+
+
+
SLA-Plan
+
+ — Zeiten der Priorität verwenden —
+ query('SELECT id, name, is_active FROM sla_plans ORDER BY name')->fetchAll() as $__sp): ?>
+ >= e($__sp['name']) ?>= $__sp['is_active'] ? '' : ' (deaktiviert)' ?>
+
+
+
+
Abteilungs-E-Mail (optional)
+
+ — Automatisch (Standardadresse der Abteilung) —
+
+ >= e($o['email']) ?> (= e($o['department_name']) ?>)
+
+ = e($curEmail) ?>
+
+
Adressen, Priorität und Postfach-Abruf pflegst du unter E-Mail-Adressen .
+
+
+ >
+ Standardabteilung (für E-Mail-Tickets ohne Zuordnung)
+
+
+
Signatur (E-Mail-Fuß für Antworten ohne Mitarbeiter-Signatur)
+
= e($v('signature', 'signature')) ?>
+
+
Administratornotizen – Interne Notizen, sichtbar für alle Administratoren
+
= e($v('notes', 'notes')) ?>
+
+
+
+
+
Mitglieder können nach dem Erstellen der Abteilung unter Mitarbeiter zugewiesen werden.
+
+
Dieser Abteilung sind noch keine Mitarbeiter zugewiesen. Die Zuweisung erfolgt beim jeweiligen Mitarbeiter unter Mitarbeiter .
+
+
+ Name E-Mail Rolle Zuordnung Status
+
+
+
+ = e($m['name']) ?>= (string) $m['id'] === $curManager ? ' (Leiter) ' : '' ?>
+ = e($m['email']) ?>
+ = $m['role'] === 'admin' ? 'Administrator' : 'Agent' ?>
+ = $m['is_primary'] ? 'Hauptabteilung' : 'Zusätzlich' ?>
+ = $m['is_active'] ? 'Aktiv' : 'Deaktiviert' ?>
+
+
+
+
+
Zuweisungen änderst du unter Mitarbeiter .
+
+
+
+
+
= $editDept ? 'Änderungen speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+
+
+
+
+
+ Name Status Mitglieder Abteilungsleiter E-Mail Erstellt Zuletzt aktualisiert
+
+
+
+ = e($d['name']) ?> = $d['is_default'] ? ' (Standard) ' : '' ?>
+ = ($d['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?>
+ = (int) $d['member_count'] ?>
+ = e($d['manager_name'] ?? '') ?>
+ = e($d['email']) ?>
+ = e($fmt($d['created_at'] ?? null)) ?>
+ = e($fmt($d['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+ Noch keine Abteilungen angelegt.
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/email_addresses.php b/DeskHub-Webseite/staff/email_addresses.php
new file mode 100644
index 00000000..c0a3d45f
--- /dev/null
+++ b/DeskHub-Webseite/staff/email_addresses.php
@@ -0,0 +1,383 @@
+query('SELECT department_id FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN)));
+$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
+$topics = help_topic_options($pdo, false, false);
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
+ try {
+ $newId = email_address_save($pdo, $id, [
+ 'email' => $_POST['email'] ?? '', 'label' => $_POST['label'] ?? '',
+ 'department_id' => $_POST['department_id'] ?? 0, 'priority_id' => $_POST['priority_id'] ?? null,
+ 'help_topic_id' => $_POST['help_topic_id'] ?? null, 'is_default' => !empty($_POST['is_default']),
+ 'is_active' => ($_POST['status'] ?? 'active') === 'active', 'notes' => $_POST['notes'] ?? '',
+ 'imap_enabled' => !empty($_POST['imap_enabled']), 'imap_host' => $_POST['host'] ?? '', 'imap_port' => $_POST['port'] ?? 993,
+ 'imap_encryption' => $_POST['encryption'] ?? 'ssl', 'imap_username' => $_POST['username'] ?? '', 'imap_password' => $_POST['password'] ?? '',
+ 'imap_folder' => $_POST['folder'] ?? '', 'imap_after_fetch' => $_POST['after_fetch'] ?? 'seen',
+ 'imap_archive_folder' => $_POST['archive_folder'] ?? '', 'imap_spam_folder' => $_POST['spam_folder'] ?? '',
+ 'smtp_enabled' => !empty($_POST['smtp_enabled']), 'smtp_host' => $_POST['smtp_host'] ?? '', 'smtp_port' => $_POST['smtp_port'] ?? 587,
+ 'smtp_encryption' => $_POST['smtp_encryption'] ?? 'tls', 'smtp_username' => $_POST['smtp_username'] ?? '', 'smtp_password' => $_POST['smtp_password'] ?? '',
+ ]);
+ audit_log($admin['id'], ($action === 'create' ? 'E-Mail-Adresse erstellt: ' : 'E-Mail-Adresse aktualisiert: ') . strtolower(trim($_POST['email'] ?? '')));
+ flash('success', $action === 'create' ? 'E-Mail-Adresse erstellt.' : 'Änderungen gespeichert.');
+ redirect('/staff/email_addresses.php');
+ } catch (EmailAddressException $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if ($action === 'delete') {
+ $delEmail = email_address_delete($pdo, (int) $_POST['id']);
+ audit_log($admin['id'], "E-Mail-Adresse gelöscht: {$delEmail}");
+ flash('success', 'E-Mail-Adresse gelöscht.');
+ redirect('/staff/email_addresses.php');
+ }
+
+ if (in_array($action, ['send_test', 'test', 'test_smtp'], true)) {
+ $id = (int) $_POST['id'];
+ try {
+ $msg = match ($action) {
+ 'send_test' => email_address_send_test($pdo, $id, $admin['email']),
+ 'test' => email_address_test_imap($pdo, $id),
+ default => email_address_test_smtp($pdo, $id),
+ };
+ flash('success', $msg);
+ } catch (EmailAddressException $e) {
+ flash('error', $e->getMessage());
+ }
+ redirect('/staff/email_addresses.php?edit=' . $id . ($action === 'test' ? '&tab=imap' : ($action === 'test_smtp' ? '&tab=smtp' : '')));
+ }
+
+ if ($action === 'bulk_add') {
+ $raw = trim($_POST['bulk_emails'] ?? '');
+ $deptId = (int) ($_POST['bulk_department_id'] ?? 0);
+ $prioId = ($_POST['bulk_priority_id'] ?? '') !== '' ? (int) $_POST['bulk_priority_id'] : null;
+
+ if (!in_array($deptId, array_map('intval', array_column($departments, 'id')), true)) {
+ $errors[] = 'Bitte eine gültige Abteilung wählen.';
+ } else {
+ // Eine Adresse pro Zeile, optional mit Komma getrennter Bezeichnung: "email@x.de, Bezeichnung"
+ $lines = array_filter(array_map('trim', preg_split('/[\r\n]+/', $raw)));
+ $added = 0;
+ $skipped = 0;
+ foreach ($lines as $line) {
+ $parts = array_map('trim', explode(',', $line, 2));
+ try {
+ email_address_save($pdo, 0, ['email' => $parts[0] ?? '', 'label' => $parts[1] ?? '', 'department_id' => $deptId, 'priority_id' => $prioId]);
+ $added++;
+ } catch (EmailAddressException $e) {
+ $skipped++;
+ }
+ }
+ audit_log($admin['id'], "E-Mail-Adressen Massen-Import: $added hinzugefügt, $skipped übersprungen");
+ flash('success', "$added Adresse(n) hinzugefügt, $skipped übersprungen (ungültig oder bereits vorhanden).");
+ redirect('/staff/email_addresses.php');
+ }
+ }
+}
+
+// --- Anzeige: Formular (neu/bearbeiten) oder Liste ---------------------------------------
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$editAddress = null;
+$editImap = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM email_addresses WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editAddress = $stmt->fetch() ?: null;
+ if ($editAddress) $editImap = email_address_imap($pdo, $editId);
+}
+$f = fn(string $k, $default = '') => $_POST[$k] ?? $default;
+$tab = in_array($_GET['tab'] ?? '', ['imap', 'smtp'], true) ? $_GET['tab'] : 'account';
+if ($errors && !empty($_POST['smtp_enabled'])) $tab = 'smtp';
+elseif ($errors && !empty($_POST['imap_enabled'])) $tab = 'imap';
+
+$addresses = $pdo->query(
+ 'SELECT ea.*, d.name AS department_name, p.name AS priority_name,
+ (SELECT COUNT(*) FROM imap_accounts ia WHERE ia.email_address_id = ea.id AND ia.is_active = 1) AS imap_active
+ FROM email_addresses ea
+ JOIN departments d ON d.id = ea.department_id
+ LEFT JOIN priorities p ON p.id = ea.priority_id
+ ORDER BY d.name, ea.email'
+)->fetchAll();
+$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
+
+$pageTitle = 'E-Mail-Adressen';
+$activePage = 'email_addresses';
+require __DIR__ . '/_header.php';
+?>
+
+
+ = e($err) ?>
+
+
+
+ $f($k, $editAddress[$col] ?? $default);
+ $imapV = fn(string $k, string $col, $default = '') => $f($k, $editImap[$col] ?? $default);
+ $curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editAddress['is_active'] ?? 1) ? 'active' : 'inactive');
+ $imapEnabled = $_POST ? !empty($_POST['imap_enabled']) : ($editImap && $editImap['is_active']);
+ $smtpEnabled = $_POST ? !empty($_POST['smtp_enabled']) : !empty($editAddress['smtp_enabled']);
+ $afterFetchVal = $imapV('after_fetch', 'after_fetch', 'seen');
+ $hasSmtpPass = !empty($editAddress['smtp_password_enc']);
+?>
+
+ = $editAddress ? 'E-Mail-Adresse' : 'Neue E-Mail-Adresse' ?>
+ — = e($editAddress['label'] ?: '') ?> <= e($editAddress['email']) ?>>
+
+
+
+ = csrf_field() ?>
+
+
+
+
+ Konto
+ Entferntes Postfach
+ Abgehend (SMTP)
+
+
+
+
E-Mail-Informationen und Einstellungen
+
Eingehende Mails an diese Adresse landen automatisch in der gewählten Abteilung; ausgehende Antworten dieser Abteilung werden mit dieser Adresse als Absender verschickt.
+
E-Mail-Adresse *
+
+
Anzeigename
+
+
Status
+
+ > Aktiv
+ > Deaktiviert
+
+
Eine deaktivierte Adresse wird weder für das Routing noch als Absender verwendet; der Abruf pausiert.
+
+
Einstellungen bei neuem Ticket
+
Abteilung *
+
+
+ >= e($d['name']) ?>= ($d['is_active'] ?? 1) ? '' : ' (deaktiviert)' ?>
+
+
+
Priorität
+
+ — Systemstandard —
+
+ >= e($p['name']) ?>
+
+
+
Hilfethema
+
+ — Systemstandard —
+
+ >= e($t['path']) ?>
+
+
+
+ >
+ Standard-Absenderadresse dieser Abteilung
+
+
+
Interne Notizen – Sei offen, sie sind vertraulich
+
= e($v('notes', 'notes')) ?>
+
+
+
+
Mails per IMAP abrufen
+
+ >
+ Abruf für diese Adresse aktivieren
+
+
Abteilung, Priorität und Hilfethema stammen aus dem Reiter „Konto“.
+ Letzter Abruf: = e(cron_last_run_label($editImap['last_run_at'] ?? null)) ?>.
+
+
+
+
+
Verschlüsselung
+
+
+ >SSL/TLS (993)
+ >STARTTLS (143)
+ >Keine
+
+
Ordner
+
+
+
Aktion nach Abruf
+
+ >Als gelesen markieren
+ >In Ordner verschieben
+ >Löschen
+
+
+ Zielordner
+
+
+
Spam-/Junk-Ordner
+
+
+
+
+
+
Ausgehende Mails (SMTP)
+
+ >
+ Eigenen SMTP-Server für diese Adresse verwenden
+
+
Ohne eigenen Server werden Mails mit dieser Absenderadresse über den globalen SMTP aus den Einstellungen verschickt.
+
+
+
+
Verschlüsselung
+
+
+ >STARTTLS (587)
+ >SSL/TLS (465)
+ >Keine
+
+
+
+
+
+
= $editAddress ? 'Änderungen speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+
+ = csrf_field() ?>
+
+
+ = e($lbl) ?>
+
+
+
+Tests verwenden die gespeicherten Werte.
+
+
+
+
+
+
+
+Eine Adresse = Abteilung + Priorität + Hilfethema + (optional) Postfach zum Abruf und eigener SMTP-Server. DeskHub wertet beim Abruf den „An:“- und „CC:“-Header aus und legt das Ticket in der passenden Abteilung ab. Ist keine Adresse passend, greift die Standardabteilung aus den Einstellungen .
+
+
+
+ E-Mail-Adresse Anzeigename Status Abteilung Priorität Abruf Eigener SMTP Zuletzt aktualisiert
+
+
+
+ = e($a['email']) ?> = $a['is_default'] ? ' (Standard) ' : '' ?>
+ = e($a['label'] ?: '–') ?>
+ = ($a['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?>
+ = e($a['department_name']) ?>
+ = e($a['priority_name'] ?? 'Systemstandard') ?>
+ = $a['imap_active'] ? '✓' : '–' ?>
+ = !empty($a['smtp_enabled']) ? '✓' : '–' ?>
+ = e($fmt($a['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+ Noch keine E-Mail-Adressen angelegt.
+
+
+
+
+
+
+ Mehrere Adressen auf einmal anlegen
+ Eine Adresse pro Zeile, optional mit Anzeigename nach einem Komma (z. B. technik@firma.de, Technik-Support).
+
+ = csrf_field() ?>
+
+
+ Abteilung
+
+ = e($d['name']) ?>
+
+ Priorität
+
+ — Systemstandard —
+ = e($p['name']) ?>
+
+ Alle anlegen
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/email_templates.php b/DeskHub-Webseite/staff/email_templates.php
new file mode 100644
index 00000000..5a6d012c
--- /dev/null
+++ b/DeskHub-Webseite/staff/email_templates.php
@@ -0,0 +1,110 @@
+ [
+ 'label' => 'Ticket erstellt (an Kunde)',
+ 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'department_name', 'ticket_url', 'lookup_url', 'site_name'],
+ 'default_subject' => 'Ihr Ticket #{ticket_number} wurde erstellt',
+ 'default_body' => email_ticket_created_default_body(),
+ ],
+ 'new_reply' => [
+ 'label' => 'Neue Antwort (an Kunde)',
+ 'placeholders' => ['ticket_number', 'subject', 'customer_name', 'message', 'ticket_url'],
+ 'default_subject' => '[#{ticket_number}] {subject}',
+ 'default_body' => 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'],
+ 'default_subject' => 'Neues Ticket [#{ticket_number}] {subject}',
+ 'default_body' => email_new_ticket_department_default_body(),
+ ],
+ 'ticket_overdue' => [
+ 'label' => 'Ticket überfällig (an Admins)',
+ 'placeholders' => ['ticket_number', 'subject', 'department_name', 'ticket_url'],
+ 'default_subject' => 'SLA überschritten: Ticket #{ticket_number}',
+ 'default_body' => email_ticket_overdue_default_body(),
+ ],
+ 'live_chat_transcript' => [
+ 'label' => 'Chatverlauf (an Kunde, nach Chatende)',
+ 'placeholders' => ['ticket_number', 'customer_name', 'transcript', 'ticket_url'],
+ 'default_subject' => 'Ihr Chatverlauf – Ticket #{ticket_number}',
+ 'default_body' => email_live_chat_transcript_default_body(),
+ ],
+];
+
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $key = $_POST['template_key'] ?? '';
+ if (!isset($templates[$key])) {
+ $errors[] = 'Unbekannte Vorlage.';
+ } else {
+ $subject = trim($_POST['subject'] ?? '');
+ $body = sanitize_staff_html(trim($_POST['body'] ?? ''));
+ if ($subject === '') $errors[] = 'Bitte einen Betreff angeben.';
+ if (trim(strip_tags($body)) === '') $errors[] = 'Bitte einen Text angeben.';
+
+ if (!$errors) {
+ set_setting("email_template_{$key}_subject", $subject);
+ set_setting("email_template_{$key}_body", $body);
+ audit_log($admin['id'], "E-Mail-Vorlage geändert: {$templates[$key]['label']}");
+ flash('success', 'Vorlage gespeichert.');
+ redirect('/staff/email_templates.php');
+ }
+ }
+
+ if (($_POST['action'] ?? '') === 'reset') {
+ $key = $_POST['template_key'] ?? '';
+ if (isset($templates[$key])) {
+ set_setting("email_template_{$key}_subject", null);
+ set_setting("email_template_{$key}_body", null);
+ audit_log($admin['id'], "E-Mail-Vorlage zurückgesetzt: {$templates[$key]['label']}");
+ flash('success', 'Vorlage auf Standard zurückgesetzt.');
+ redirect('/staff/email_templates.php');
+ }
+ }
+}
+
+$pageTitle = 'E-Mail-Vorlagen';
+$activePage = 'email_templates';
+require __DIR__ . '/_header.php';
+?>
+
+E-Mail-Vorlagen
+Texte der automatischen E-Mails anpassen. Platzhalter in geschweiften Klammern werden beim Versand durch die echten Werte ersetzt. Der Branding-Rahmen (Kopfzeile mit Firmenname, Fußzeile) wird beim Versand automatisch um diesen Text herum ergänzt – hier nur den Inhalt der Karte anpassen.
+
+
+ = e($err) ?>
+
+
+ $tpl):
+ $subject = get_setting("email_template_{$key}_subject") ?: $tpl['default_subject'];
+ $body = get_setting("email_template_{$key}_body") ?: $tpl['default_body'];
+ $isCustomized = get_setting("email_template_{$key}_subject") !== null;
+?>
+
+
= e($tpl['label']) ?> angepasst
+
Verfügbare Platzhalter: {= $ph ?>}
+
+ = csrf_field() ?>
+
+ Betreff
+
+ Text
+ = e($body) ?>
+
+ Speichern
+
+ Auf Standard zurücksetzen
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/escalation_rules.php b/DeskHub-Webseite/staff/escalation_rules.php
new file mode 100644
index 00000000..3a34cd7e
--- /dev/null
+++ b/DeskHub-Webseite/staff/escalation_rules.php
@@ -0,0 +1,338 @@
+query('SELECT * 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();
+
+$triggerTypes = [
+ 'no_reply' => 'Keine Mitarbeiter-Antwort seit X Stunden',
+ 'no_update' => 'Kein Update seit X Stunden',
+ 'overdue' => 'Ticket ist überfällig (SLA überschritten)',
+ 'open_too_long' => 'Ticket offen seit X Stunden',
+];
+
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'save') {
+ $id = (int)($_POST['id'] ?? 0);
+ $name = trim($_POST['name'] ?? '');
+ $isActive = !empty($_POST['is_active']) ? 1 : 0;
+ $triggerType = $_POST['trigger_type'] ?? 'no_reply';
+ $triggerHours = max(1, (int)($_POST['trigger_hours'] ?? 24));
+ $condStatus = trim($_POST['condition_status'] ?? '');
+ $condPrioId = ($_POST['condition_priority_id'] ?? '') !== '' ? (int)$_POST['condition_priority_id'] : null;
+ $condDeptId = ($_POST['condition_dept_id'] ?? '') !== '' ? (int)$_POST['condition_dept_id'] : null;
+ $actPrioId = ($_POST['action_set_priority_id'] ?? '') !== '' ? (int)$_POST['action_set_priority_id'] : null;
+ $actStaffId = ($_POST['action_assign_staff_id'] ?? '') !== '' ? (int)$_POST['action_assign_staff_id'] : null;
+ $actStatus = $_POST['action_set_status'] ?? '';
+ $actNotAdmin = !empty($_POST['action_notify_admin']) ? 1 : 0;
+ $actNotStaff = !empty($_POST['action_notify_staff']) ? 1 : 0;
+ $sortOrder = (int)($_POST['sort_order'] ?? 0);
+
+ if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
+ if (!array_key_exists($triggerType, $triggerTypes)) $errors[] = 'Ungültiger Trigger.';
+
+ if (!$errors) {
+ $data = [$name, $isActive, $triggerType, $triggerHours, $condStatus ?: null,
+ $condPrioId, $condDeptId, $actPrioId, $actStaffId,
+ $actStatus ?: null, $actNotAdmin, $actNotStaff, $sortOrder];
+ if ($id) {
+ $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");
+ } 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()]));
+ audit_log($staff['id'], "Eskalationsregel erstellt: $name");
+ }
+ flash('success', 'Eskalationsregel gespeichert.');
+ redirect('/staff/escalation_rules.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int)($_POST['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");
+ flash('success', 'Regel gelöscht.');
+ redirect('/staff/escalation_rules.php');
+ }
+
+ if ($action === 'toggle') {
+ $id = (int)($_POST['id'] ?? 0);
+ $pdo->prepare('UPDATE escalation_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
+ redirect('/staff/escalation_rules.php');
+ }
+}
+
+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 = [];
+}
+
+$editId = (int)($_GET['edit'] ?? 0);
+$editRule = null;
+if ($editId) {
+ foreach ($rules as $r) { if ($r['id'] === $editId) { $editRule = $r; break; } }
+}
+
+$pageTitle = 'Eskalationsregeln';
+$activePage = 'ticket_rules';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
+ Eskalationsregeln werden stündlich per Cron geprüft (cron/check_escalations.php).
+ Jede Regel wird pro Ticket nur einmal angewendet.
+
+
+= e($e) ?>
+
+
+
+
+
+
= $r ? 'Regel bearbeiten' : 'Neue Eskalationsregel' ?>
+
+ = csrf_field() ?>
+
+
+
+
+
+
+
Auslöser
+
+
+ Trigger
+
+ $tl): ?>
+ >= e($tl) ?>
+
+
+
+
+ Stunden
+
+
+
+
+
+
+
Bedingungen (optional – leer = alle Tickets)
+
+
+ Nur Status
+
+ Kommagetrennt
+
+
+ Nur Priorität
+
+ Alle
+
+ >= e($p['name']) ?>
+
+
+
+
+ Nur Abteilung
+
+ Alle
+
+ >= e($d['name']) ?>
+
+
+
+
+
+
+
+
Aktionen
+
+
+ Priorität setzen
+
+ – keine Änderung –
+
+ >= e($p['name']) ?>
+
+
+
+
+ Mitarbeiter zuweisen
+
+ – keine Änderung –
+
+ >= e($s['name']) ?>
+
+
+
+
+ Status setzen
+
+ – keine Änderung –
+ 'Offen','pending'=>'Wartet auf Kunde','on_hold'=>'Pausiert'] as $sv => $sl): ?>
+ >= $sl ?>
+
+
+
+
+
+
+ >
+ Admin benachrichtigen
+
+
+ >
+ Zugewiesenen Mitarbeiter benachrichtigen
+
+
+
+
+
+
+
+
+
+
+
+
+
Noch keine Eskalationsregeln. Erstellen Sie eine neue Regel mit dem Button oben.
+
+
+
+
+ Name
+ Auslöser
+ Aktionen
+ Angewendet
+ Status
+
+
+
+
+
+
+ = e($r['name']) ?>
+
+ = e($triggerTypes[$r['trigger_type']] ?? $r['trigger_type']) ?>
+
+ (= (int)$r['trigger_hours'] ?> Std)
+
+
+
+ = $r['cond_prio_name'] ? '· Priorität: '.e($r['cond_prio_name']) : '' ?>
+ = $r['dept_name'] ? '· Abteilung: '.e($r['dept_name']) : '' ?>
+
+
+
+
+
+
+
+
+
+
+ = implode(', ', $acts) ?: 'Nur benachrichtigen ' ?>
+
+ = (int)$r['applied_count'] ?>×
+
+
+
+ = $r['is_active'] ? 'Aktiv' : 'Inaktiv' ?>
+
+
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+
+ = $r['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?>
+
+ ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
+
Crontab-Eintrag
+
0 * * * * php /pfad/zu/deskhub/cron/check_escalations.php
+
Stündliche Prüfung. Letzte Ausführung: = e(get_setting('cron_check_escalations_last_run', 'noch nie')) ?>
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/export.php b/DeskHub-Webseite/staff/export.php
new file mode 100644
index 00000000..94e11b10
--- /dev/null
+++ b/DeskHub-Webseite/staff/export.php
@@ -0,0 +1,130 @@
+prepare($sql);
+ $stmt->execute($params);
+
+ $rows = [];
+ foreach ($stmt->fetchAll() as $t) {
+ [$statusLabel] = status_label($t['status']);
+ $rows[] = [
+ $t['ticket_number'], $t['subject'], $t['requester_name'], $t['requester_email'],
+ $t['department_name'], $t['priority_name'], $statusLabel,
+ $t['staff_name'] ?: '', $t['created_at'], $t['resolution_due_at'],
+ ];
+ }
+
+ csv_response('tickets_' . date('Y-m-d') . '.csv',
+ ['Ticketnummer', 'Betreff', 'Anfrager', 'E-Mail', 'Abteilung', 'Priorität', 'Status', 'Zugewiesen', 'Erstellt', 'Fällig'],
+ $rows);
+}
+
+if ($type === 'report') {
+ // Berichte sind seit Kurzem für alle Mitarbeiter sichtbar (abteilungsbeschränkt für Nicht-Admins),
+ // der Export folgt derselben Regel wie staff/reports.php.
+ $dim = $_GET['dim'] ?? 'dept';
+ $dimColumn = ['dept' => 'department_id', 'topic' => 'help_topic_id', 'agent' => 'assigned_staff_id'][$dim] ?? 'department_id';
+
+ $preset = $_GET['range'] ?? '30d';
+ $today = new DateTime('today');
+ switch ($preset) {
+ case '7d': $from = (clone $today)->modify('-6 days'); $to = clone $today; break;
+ case 'thismonth': $from = new DateTime(date('Y-m-01')); $to = clone $today; break;
+ case 'thisyear': $from = new DateTime(date('Y-01-01')); $to = clone $today; break;
+ case 'custom':
+ $from = !empty($_GET['from']) ? new DateTime($_GET['from']) : (clone $today)->modify('-29 days');
+ $to = !empty($_GET['to']) ? new DateTime($_GET['to']) : clone $today;
+ break;
+ default: $from = (clone $today)->modify('-29 days'); $to = clone $today;
+ }
+ if ($from > $to) { [$from, $to] = [$to, $from]; }
+ $rangeFrom = $from->format('Y-m-d 00:00:00');
+ $rangeTo = $to->format('Y-m-d 23:59:59');
+
+ $rows = build_report_table($pdo, $dimColumn, $rangeFrom, $rangeTo, $myDeptIds);
+
+ $labelMap = match ($dim) {
+ 'topic' => $pdo->query('SELECT id, name FROM help_topics')->fetchAll(PDO::FETCH_KEY_PAIR),
+ 'agent' => $pdo->query('SELECT id, name FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR),
+ default => $pdo->query('SELECT id, name FROM departments')->fetchAll(PDO::FETCH_KEY_PAIR),
+ };
+ $noneLabel = match ($dim) {
+ 'topic' => 'Kein Hilfethema',
+ 'agent' => 'Nicht zugewiesen',
+ default => 'Ohne Abteilung',
+ };
+
+ $out = [];
+ foreach ($rows as $key => $r) {
+ $label = $key === 'none' ? $noneLabel : ($labelMap[(int) $key] ?? "#{$key}");
+ $out[] = [
+ $label, $r['geoeffnet'], $r['zugewiesen'], $r['ueberfaellig'], $r['geschlossen'],
+ $r['wieder_geoeffnet'], $r['geloescht'],
+ $r['servicezeit'] ?? '', $r['reaktionszeit'] ?? '',
+ ];
+ }
+
+ csv_response('bericht_' . $dim . '_' . date('Y-m-d') . '.csv',
+ ['Bezeichnung', 'Geöffnet', 'Zugewiesen', 'Überfällig', 'Geschlossen', 'Wieder geöffnet', 'Gelöscht', 'Servicezeit (Std)', 'Reaktionszeit (Std)'],
+ $out);
+}
+
+http_response_code(400);
+echo 'Unbekannter Export-Typ.';
diff --git a/DeskHub-Webseite/staff/forgot_password.php b/DeskHub-Webseite/staff/forgot_password.php
new file mode 100644
index 00000000..892f88c0
--- /dev/null
+++ b/DeskHub-Webseite/staff/forgot_password.php
@@ -0,0 +1,72 @@
+prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
+ $stmt->execute([$email]);
+ $staff = $stmt->fetch();
+
+ if ($staff) {
+ $token = create_password_reset('staff', $email);
+ $link = base_url() . '/staff/reset_password.php?token=' . urlencode($token);
+ email_send_and_log($email, 'Passwort zurücksetzen (Mitarbeiterbereich)', email_password_reset_body($link), null, 'Passwort-zurücksetzen-Mail (Mitarbeiter)');
+ }
+ $sent = true;
+ }
+}
+
+$pageTitle = 'Passwort vergessen';
+?>
+
+
+
+
+
+Passwort vergessen – Mitarbeiterbereich
+ ">
+
+
+
+
+
+
Passwort vergessen
+
Mitarbeiterbereich – wir senden Ihnen einen Link zum Zurücksetzen.
+
+
= e($err) ?>
+
+
+
Falls ein aktives Mitarbeiterkonto mit dieser E-Mail-Adresse existiert, wurde soeben ein Link zum Zurücksetzen versendet.
+
← Zur Anmeldung
+
+
+ = csrf_field() ?>
+ E-Mail-Adresse
+
+
+ Link anfordern
+
+
+
← Zur Anmeldung
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/help_topics.php b/DeskHub-Webseite/staff/help_topics.php
new file mode 100644
index 00000000..dbc511d4
--- /dev/null
+++ b/DeskHub-Webseite/staff/help_topics.php
@@ -0,0 +1,264 @@
+query('SELECT department_id FROM help_topics')->fetchAll(PDO::FETCH_COLUMN)));
+$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $id = $action === 'update' ? (int) ($_POST['id'] ?? 0) : 0;
+ try {
+ help_topic_save($pdo, $id, [
+ 'name' => $_POST['name'] ?? '', 'department_id' => $_POST['department_id'] ?? 0, 'priority_id' => $_POST['priority_id'] ?? 0,
+ 'is_active' => ($_POST['status'] ?? 'active') === 'active', 'is_public' => ($_POST['type'] ?? 'public') === 'public',
+ 'parent_id' => $_POST['parent_id'] ?? null, 'sla_plan_id' => $_POST['sla_plan_id'] ?? null,
+ 'notes' => $_POST['notes'] ?? '', 'sort_order' => $_POST['sort_order'] ?? 0,
+ ]);
+ audit_log($admin['id'], ($action === 'create' ? 'Hilfethema erstellt: ' : 'Hilfethema aktualisiert: ') . trim($_POST['name'] ?? ''));
+ flash('success', $action === 'create' ? 'Hilfethema erstellt.' : 'Änderungen gespeichert.');
+ redirect('/staff/help_topics.php');
+ } catch (AdminModelException $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if ($action === 'delete') {
+ $deleted = help_topic_delete($pdo, (int) $_POST['id']);
+ audit_log($admin['id'], "Hilfethema gelöscht: {$deleted}");
+ flash('success', 'Hilfethema gelöscht.');
+ redirect('/staff/help_topics.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$editTopic = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM help_topics WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editTopic = $stmt->fetch() ?: null;
+}
+
+$topicFields = $globalFields = [];
+$fieldTypeLabels = ['text' => 'Einzeiliger Text', 'textarea' => 'Mehrzeiliger Text', 'select' => 'Dropdown-Auswahl', 'checkbox' => 'Checkbox (Ja/Nein)', 'number' => 'Zahl', 'date' => 'Datum'];
+if ($editTopic) {
+ $cfStmt = $pdo->prepare('SELECT * FROM custom_fields WHERE help_topic_id = ? ORDER BY sort_order, label');
+ $cfStmt->execute([$editTopic['id']]);
+ $topicFields = $cfStmt->fetchAll();
+ $globalFields = $pdo->query('SELECT * FROM custom_fields WHERE help_topic_id IS NULL AND is_active = 1 ORDER BY sort_order, label')->fetchAll();
+}
+$sortMode = ($_GET['sort'] ?? 'alpha') === 'manual' ? 'manual' : 'alpha';
+$topics = $pdo->query(
+ 'SELECT h.*, d.name AS department_name, p.name AS priority_name
+ FROM help_topics h
+ JOIN departments d ON d.id = h.department_id
+ JOIN priorities p ON p.id = h.priority_id
+ ORDER BY ' . ($sortMode === 'manual' ? 'h.sort_order, h.name' : 'h.name')
+)->fetchAll();
+// Anzeigename mit Elternpfad
+$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);
+};
+$fmt = fn(?string $dt) => $dt ? date('d.m.Y H:i', strtotime($dt)) : '–';
+
+$pageTitle = 'Hilfethemen';
+$activePage = 'help_topics';
+require __DIR__ . '/_header.php';
+?>
+
+
+ = e($err) ?>
+
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editTopic[$col] ?? $default);
+ $curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editTopic['is_active'] ?? 1) ? 'active' : 'inactive');
+ $curType = $_POST ? ($_POST['type'] ?? 'public') : (($editTopic['is_public'] ?? 1) ? 'public' : 'internal');
+ $curParent = (string) $v('parent_id', 'parent_id');
+ $excluded = $editTopic ? array_merge([(int) $editTopic['id']], help_topic_descendants($pdo, (int) $editTopic['id'])) : [];
+?>
+
+ = $editTopic ? 'Hilfethema aktualisieren' : 'Neues Hilfethema' ?>
+ — = e($editTopic['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+
+ Hilfethemeninformationen
+ Optionen bei neuem Ticket
+ Formulare
+
+
+
+
Thema *
+
+
Status *
+
+ >Aktiv
+ >Deaktiviert
+
+
Typ *
+
+ > Öffentlich
+ > Vertraulich/Intern
+
+
Öffentliche Themen sehen auch Kunden im Portal; interne nur Mitarbeiter.
+
Übergeordnetes Thema
+
+ — Oberste Hilfethemenebene —
+
+ >= e($pathOf((int) $t['id'])) ?>
+
+
+
Interne Notizen – Sei offen, sie sind vertraulich
+
= e($v('notes', 'notes')) ?>
+
+
+
+
Diese Werte werden beim Anlegen eines Tickets mit diesem Thema automatisch gesetzt.
+
Abteilung *
+
+
+ >= e($d['name']) ?>
+
+
+
Priorität *
+
+
+ >= e($p['name']) ?>
+
+
+
SLA-Plan
+
+ — Abteilung/Priorität bestimmt die Zeiten —
+ query('SELECT id, name, is_active FROM sla_plans ORDER BY name')->fetchAll() as $__sp): ?>
+ >= e($__sp['name']) ?>= $__sp['is_active'] ? '' : ' (deaktiviert)' ?>
+
+
+
Sortierung
+
+
Zusatzfelder pro Thema legst du unter Zusatzfelder an.
+
+
+
+
Formulare
+
+
Zusatzfelder lassen sich nach dem Erstellen des Hilfethemas zuordnen.
+
+
Diese Zusatzfelder erscheinen auf dem Ticketformular, sobald Kunden dieses Hilfethema wählen.
+
+
Für dieses Thema gibt es noch keine eigenen Felder.
+
+
+ Bezeichnung Typ Pflicht Aktiv
+
+
+
+ = e($cf['label']) ?>
+ = e($fieldTypeLabels[$cf['field_type']] ?? $cf['field_type']) ?>
+ = $cf['is_required'] ? '✓' : '–' ?>
+ = $cf['is_active'] ? '✓' : '–' ?>
+ Bearbeiten
+
+
+
+
+
+
+ Neues Feld für dieses Thema
+
+
Felder für alle Themen
+
Gelten zusätzlich bei jedem Thema und werden unter Ticket-Felder verwaltet.
+
= e($cf['label']) ?>= $cf['is_required'] ? ' *' : '' ?>
+
+
+
+
+
+
+
+
+
+
+
+
+Hilfethemen erscheinen im Kundenportal als Auswahl bei der Ticket-Erstellung und setzen automatisch Abteilung und Priorität.
+
+
+
+ Hilfethema Status Typ Priorität Abteilung Zuletzt aktualisiert Erstellt
+
+
+
+ = e($pathOf((int) $t['id'])) ?>
+ = $t['is_active'] ? 'Aktiv' : 'Deaktiviert' ?>
+ = ($t['is_public'] ?? 1) ? 'Öffentlich' : 'Intern' ?>
+ = e($t['priority_name']) ?>
+ = e($t['department_name']) ?>
+ = e($fmt($t['updated_at'] ?? null)) ?>
+ = e($fmt($t['created_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+ Noch keine Hilfethemen angelegt.
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/imap_accounts.php b/DeskHub-Webseite/staff/imap_accounts.php
new file mode 100644
index 00000000..f5b42910
--- /dev/null
+++ b/DeskHub-Webseite/staff/imap_accounts.php
@@ -0,0 +1,262 @@
+query('SELECT department_id FROM imap_accounts WHERE department_id IS NOT NULL')->fetchAll(PDO::FETCH_COLUMN)));
+$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $label = trim($_POST['label'] ?? '') ?: 'Postfach';
+ $host = trim($_POST['host'] ?? '');
+ $port = (int) ($_POST['port'] ?? 993) ?: 993;
+ $enc = in_array($_POST['encryption'] ?? 'ssl', ['ssl','tls','none'], true) ? $_POST['encryption'] : 'ssl';
+ $username = trim($_POST['username'] ?? '');
+ $folder = trim($_POST['folder'] ?? '') ?: 'INBOX';
+ $afterFetch = in_array($_POST['after_fetch'] ?? 'seen', ['seen','archive','delete'], true) ? $_POST['after_fetch'] : 'seen';
+ $archiveFolder = trim($_POST['archive_folder'] ?? '') ?: 'Processed';
+ $spamFolder = trim($_POST['spam_folder'] ?? '') ?: 'Junk';
+ $deptId = $_POST['department_id'] !== '' ? (int) $_POST['department_id'] : null;
+ $prioId = $_POST['priority_id'] !== '' ? (int) $_POST['priority_id'] : null;
+ $isActive = !empty($_POST['is_active']) ? 1 : 0;
+ $plainPass = trim($_POST['password'] ?? '');
+
+ if ($host === '') $errors[] = 'Bitte einen Server (Host) angeben.';
+ if ($username === '') $errors[] = 'Bitte einen Benutzernamen angeben.';
+ if ($action === 'create' && $plainPass === '') $errors[] = 'Bitte ein Passwort angeben.';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $pdo->prepare(
+ 'INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, spam_folder, department_id, priority_id, is_active, created_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
+ )->execute([
+ $label, $host, $port, $enc, $username, encrypt_secret($plainPass), $folder,
+ $afterFetch, $archiveFolder, $spamFolder, $deptId, $prioId, $isActive, db_now(),
+ ]);
+ audit_log($staff['id'], "IMAP-Postfach angelegt: {$label} ({$host})");
+ flash('success', 'Postfach angelegt.');
+ } else {
+ $id = (int) $_POST['id'];
+ if ($plainPass !== '') {
+ $pdo->prepare(
+ 'UPDATE imap_accounts SET label=?, host=?, port=?, encryption=?, username=?, password_enc=?, folder=?, after_fetch=?, archive_folder=?, spam_folder=?, department_id=?, priority_id=?, is_active=? WHERE id=?'
+ )->execute([$label, $host, $port, $enc, $username, encrypt_secret($plainPass), $folder, $afterFetch, $archiveFolder, $spamFolder, $deptId, $prioId, $isActive, $id]);
+ } else {
+ // Passwort leer = unverändert lassen
+ $pdo->prepare(
+ 'UPDATE imap_accounts SET label=?, host=?, port=?, encryption=?, username=?, folder=?, after_fetch=?, archive_folder=?, spam_folder=?, department_id=?, priority_id=?, is_active=? WHERE id=?'
+ )->execute([$label, $host, $port, $enc, $username, $folder, $afterFetch, $archiveFolder, $spamFolder, $deptId, $prioId, $isActive, $id]);
+ }
+ audit_log($staff['id'], "IMAP-Postfach aktualisiert: {$label} ({$host})");
+ flash('success', 'Postfach gespeichert.');
+ }
+ redirect('/staff/imap_accounts.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $lblStmt = $pdo->prepare('SELECT label FROM imap_accounts WHERE id = ?');
+ $lblStmt->execute([$id]);
+ $lbl = $lblStmt->fetchColumn();
+ $pdo->prepare('DELETE FROM imap_accounts WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "IMAP-Postfach gelöscht: {$lbl}");
+ flash('success', 'Postfach gelöscht.');
+ redirect('/staff/imap_accounts.php');
+ }
+
+ if ($action === 'test') {
+ $id = (int) $_POST['id'];
+ $stmt = $pdo->prepare('SELECT * FROM imap_accounts WHERE id = ?');
+ $stmt->execute([$id]);
+ $acc = $stmt->fetch();
+ if (!$acc) {
+ flash('error', 'Postfach nicht gefunden.');
+ } elseif (!function_exists('imap_open')) {
+ flash('error', 'PHP-IMAP-Erweiterung (php-imap) ist auf diesem Server nicht installiert.');
+ } else {
+ $sslFlag = match ($acc['encryption']) {
+ 'ssl' => '/imap/ssl', 'tls' => '/imap/tls', default => '/imap/notls',
+ };
+ $mailbox = '{' . $acc['host'] . ':' . $acc['port'] . $sslFlag . '}' . $acc['folder'];
+ $mbox = @imap_open($mailbox, $acc['username'], decrypt_secret($acc['password_enc']), 0, 1);
+ if ($mbox) {
+ $unseen = imap_search($mbox, 'UNSEEN') ?: [];
+ imap_close($mbox);
+ flash('success', "Verbindung zu „{$acc['label']}" . "\" erfolgreich. " . count($unseen) . ' ungelesene Mail(s) im Postfach.');
+ } else {
+ flash('error', "Verbindung zu „{$acc['label']}\" fehlgeschlagen: " . imap_last_error());
+ }
+ }
+ redirect('/staff/imap_accounts.php');
+ }
+}
+
+$accounts = $pdo->query(
+ 'SELECT ia.*, d.name AS department_name, p.name AS priority_name
+ FROM imap_accounts ia
+ LEFT JOIN departments d ON d.id = ia.department_id
+ LEFT JOIN priorities p ON p.id = ia.priority_id
+ WHERE ia.email_address_id IS NULL
+ ORDER BY ia.id'
+)->fetchAll();
+
+$editId = $_GET['edit'] ?? null;
+$editAcc = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM imap_accounts WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editAcc = $stmt->fetch();
+}
+
+$pageTitle = 'Postfächer (E-Mail-Abruf)';
+$activePage = 'imap_accounts';
+require __DIR__ . '/_header.php';
+?>
+
+
+ Postfächer werden jetzt direkt bei der jeweiligen
E-Mail-Adresse im Reiter „Entferntes Postfach“ eingerichtet.
+ Diese Seite zeigt nur noch ältere, nicht verknüpfte Postfächer.
+
+Postfächer für den E-Mail-Abruf
+
+ Jedes Postfach wird beim Abruf (Cronjob fetch_emails.php) einzeln per IMAP abgefragt.
+ Nutzt ihr getrennte Mail-Konten pro Abteilung (statt eines Sammelpostfachs mit Weiterleitungen),
+ legt hier einfach für jede Abteilung ein eigenes Postfach an und wählt die passende Standardabteilung –
+ die Abteilungs-Routing-Tabelle per Empfängeradresse ist dann optional
+ und greift nur noch zusätzlich, falls ein einzelnes Postfach doch Mails für mehrere Abteilungen empfängt.
+
+
+
+ = e($err) ?>
+
+
+
+
+
+
+ Postfach Server Standardabteilung Aktiv Letzter Abruf
+
+
+
+ = e($a['label']) ?> = e($a['username']) ?>
+ = e($a['host']) ?>:= e($a['port']) ?>
+ = e($a['department_name'] ?? 'Automatisch (Routing/Standard)') ?>
+ = $a['is_active'] ? '✓' : '–' ?>
+ = e(cron_last_run_label($a['last_run_at'] ?? null)) ?>
+
+
+ = csrf_field() ?>
+
+
+ Testen
+ ·
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+ Noch kein Postfach angelegt.
+
+
+
+
+
+
+
+
= $editAcc ? 'Postfach bearbeiten' : 'Neues Postfach' ?>
+
+ = csrf_field() ?>
+
+
+ Bezeichnung
+
+
+
+
+
+ Postfach / Ordner
+
+
+
+ Verschlüsselung
+
+ >SSL/TLS (993)
+ >STARTTLS (143)
+ >Keine
+
+
+
+ Standardabteilung
+
+ Automatisch (Routing-Tabelle / globale Standardabteilung)
+
+ >= e($d['name']) ?>
+
+
+ Standardpriorität (optional)
+
+ Automatisch
+
+ >= e($p['name']) ?>
+
+
+ Aktion nach Abruf
+
+ >Als gelesen markieren
+ >In Ordner verschieben
+ >Löschen
+
+
+ Zielordner
+
+
+
+
Spam-/Junk-Ordner
+
+
Wird verwendet, wenn ein eingegangenes E-Mail-Ticket als Spam markiert wird.
+
+
+ >
+ Aktiv (wird beim Abruf berücksichtigt)
+
+
+
= $editAcc ? 'Speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/import_osticket.php b/DeskHub-Webseite/staff/import_osticket.php
new file mode 100644
index 00000000..9540cdbd
--- /dev/null
+++ b/DeskHub-Webseite/staff/import_osticket.php
@@ -0,0 +1,353 @@
+ 0) ob_end_clean();
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => false, 'error' => $err['message'] . ' in ' . basename($err['file']) . ':' . $err['line']]);
+ }
+ });
+}
+
+@ini_set('memory_limit', '1024M');
+@set_time_limit(300);
+
+function import_json(array $data): never
+{
+ while (ob_get_level() > 0) ob_end_clean();
+ header('Content-Type: application/json');
+ echo json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE);
+ exit;
+}
+function import_ajax_guard(): void
+{
+ header('Content-Type: application/json');
+ if (!hash_equals($_SESSION['csrf_token'] ?? '', (string) ($_POST['csrf_token'] ?? ''))) import_json(['ok' => false, 'error' => 'CSRF']);
+}
+
+$stateDir = osticket_state_dir();
+
+// ── Chunked Upload ─────────────────────────────────────────────────────────
+if (($_POST['action'] ?? '') === 'upload_chunk') {
+ import_ajax_guard();
+ $chunk = $_FILES['chunk'] ?? null;
+ $idx = (int) ($_POST['chunk_index'] ?? 0);
+ $total = (int) ($_POST['total_chunks'] ?? 1);
+ $tmpFile = $stateDir . '/upload.sql';
+ if (!$chunk || $chunk['error'] !== UPLOAD_ERR_OK) import_json(['ok' => false, 'error' => 'Chunk-Fehler']);
+ $fh = fopen($tmpFile, $idx === 0 ? 'wb' : 'ab');
+ fwrite($fh, file_get_contents($chunk['tmp_name']));
+ fclose($fh);
+ $done = ($idx + 1) >= $total;
+ if ($done) {
+ $_SESSION['import_sql_path'] = $tmpFile;
+ $_SESSION['import_filename'] = basename((string) ($_POST['filename'] ?? 'import.sql'));
+ @unlink($stateDir . '/state.json');
+ @unlink($stateDir . '/entries.jsonl');
+ @unlink($stateDir . '/entry_map.jsonl');
+ }
+ import_json(['ok' => true, 'done' => $done, 'chunk' => $idx]);
+}
+
+// ── Dateipfad ──────────────────────────────────────────────────────────────
+if (($_POST['action'] ?? '') === 'use_path') {
+ verify_csrf();
+ $filename = basename(trim($_POST['sql_filename'] ?? ''));
+ $fullPath = realpath(__DIR__ . '/../' . $filename);
+ $rootDir = realpath(__DIR__ . '/..');
+ if (!$filename || !$fullPath || strpos($fullPath, $rootDir) !== 0 || !file_exists($fullPath)) {
+ flash('error', 'Datei nicht gefunden: ' . $filename); redirect('/staff/import_osticket.php');
+ }
+ $_SESSION['import_sql_path'] = $fullPath;
+ $_SESSION['import_filename'] = $filename;
+ foreach (['state.json', 'entries.jsonl', 'entry_map.jsonl'] as $f) @unlink($stateDir . '/' . $f);
+ redirect('/staff/import_osticket.php?step=analyze');
+}
+
+// ── Analyse ────────────────────────────────────────────────────────────────
+if (($_POST['action'] ?? '') === 'analyze_chunk') {
+ import_ajax_guard();
+ $tmp = $_SESSION['import_sql_path'] ?? '';
+ if (!$tmp || !file_exists($tmp)) import_json(['ok' => false, 'error' => 'Datei fehlt']);
+ try {
+ $res = osticket_analyze_step($tmp, $stateDir, (int) ($_POST['byte_offset'] ?? 0));
+ } catch (Throwable $e) {
+ import_json(['ok' => false, 'error' => $e->getMessage()]);
+ }
+ import_json(['ok' => true, 'done' => $res['done'], 'byte_offset' => $res['offset'], 'file_size' => filesize($tmp), 'counts' => $res['counts']]);
+}
+
+// ── Import starten ─────────────────────────────────────────────────────────
+if (($_POST['action'] ?? '') === 'start') {
+ verify_csrf();
+ $allowed = ['users', 'structure', 'tickets', 'threads', 'attachments'];
+ $_SESSION['import_what'] = array_values(array_intersect($allowed, (array) ($_POST['import'] ?? [])));
+ redirect('/staff/import_osticket.php?step=run');
+}
+
+// ── Reset ──────────────────────────────────────────────────────────────────
+if (($_POST['action'] ?? '') === 'reset_import') {
+ import_ajax_guard();
+ $type = $_POST['reset_type'] ?? 'all';
+ if ($type === 'threads_only') {
+ $pdo->exec('DELETE FROM ticket_attachments');
+ $pdo->exec('DELETE FROM ticket_threads');
+ } else {
+ $pdo->exec('DELETE FROM ticket_attachments');
+ $pdo->exec('DELETE FROM ticket_threads');
+ $pdo->exec('DELETE FROM ticket_ratings');
+ $pdo->exec('DELETE FROM tickets');
+ $pdo->exec('DELETE FROM users WHERE id NOT IN (SELECT DISTINCT user_id FROM tickets WHERE user_id IS NOT NULL)');
+ }
+ @unlink($stateDir . '/entry_map.jsonl');
+ audit_log($staff['id'], 'Import-Reset: ' . $type);
+ import_json(['ok' => true]);
+}
+
+// ── Import-Schritt ─────────────────────────────────────────────────────────
+if (($_POST['action'] ?? '') === 'import_chunk') {
+ import_ajax_guard();
+ $pass = (string) ($_POST['pass'] ?? '');
+ $offset = (int) ($_POST['offset'] ?? 0);
+ if (!in_array($pass, ['users', 'structure', 'tickets', 'threads', 'attachments'], true)) import_json(['ok' => false, 'error' => 'Unbekannter Durchgang']);
+ if ($pass === 'threads' && $offset === 0) @unlink($stateDir . '/entry_map.jsonl');
+ try {
+ $res = osticket_import_step($pdo, $stateDir, $pass, $offset, (string) ($_SESSION['import_sql_path'] ?? ''));
+ } catch (Throwable $e) {
+ import_json(['ok' => false, 'error' => $e->getMessage()]);
+ }
+ $importWhat = $_SESSION['import_what'] ?? [];
+ if ($res['done'] && $pass === end($importWhat)) {
+ $tmp = $_SESSION['import_sql_path'] ?? '';
+ if ($tmp && file_exists($tmp) && str_starts_with($tmp, $stateDir)) @unlink($tmp);
+ osticket_state_clear($stateDir);
+ unset($_SESSION['import_sql_path'], $_SESSION['import_what'], $_SESSION['import_filename']);
+ audit_log($staff['id'], 'osTicket Import abgeschlossen');
+ }
+ import_json(['ok' => true] + $res);
+}
+
+// ── Seite ──────────────────────────────────────────────────────────────────
+$step = $_GET['step'] ?? '1';
+$sqlPath = $_SESSION['import_sql_path'] ?? '';
+$filename = $_SESSION['import_filename'] ?? '';
+$fileSize = $sqlPath && file_exists($sqlPath) ? filesize($sqlPath) : 0;
+
+$pageTitle = 'osTicket Import';
+$activePage = 'settings';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
+ 'Hochladen','analyze'=>'Analysieren','2'=>'Optionen','run'=>'Import'] as $s=>$l): ?>
+
=$l?>
+
+
+
+
+
+
SQL-Datei hochladen oder Pfad angeben
+
Unterstützt werden SQL-Dumps von osTicket 1.10 bis 1.18 (phpMyAdmin-Export oder mysqldump, auch mit anderem Tabellenpräfix als ost_). Importiert werden Kunden, Abteilungen, Hilfethemen, Tickets, Nachrichten und Anhänge.
+
Option 1: Datei in den DeskHub-Ordner legen (für große Dateien empfohlen)
+
+ = csrf_field() ?>
+
+
+ Verwenden →
+
+
+
Option 2: Direkt hochladen (in 5-MB-Teilen)
+
+
+
Hochladen →
+
+
+
+
+
+
Analysiere: = e($filename) ?> (= round($fileSize/1024/1024,1) ?> MB)
+
Liest die Datei in 4-MB-Blöcken. Bei sehr großen Dateien (Anhänge, Sitzungsdaten) kann das einige Minuten dauern.
+
+
Starte...
+
+ 'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?>
+
+
+
+
Weiter →
+
+
+
+
+
+
+
Was soll importiert werden?
+
+ 'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?>
+
+
= number_format((int) $counts[$k], 0, ',', '.') ?>
+
=$l?>
+
+
+
+
+ = csrf_field() ?>
+
+ ['Kunden', 'Name und E-Mail-Adresse'],
+ 'structure' => ['Abteilungen und Hilfethemen', 'Fehlende werden angelegt, gleichnamige vorhandene wiederverwendet. Tickets landen damit in der richtigen Abteilung.'],
+ 'tickets' => ['Tickets', 'Betreff, Nummer, Status, Priorität, Abteilung, Hilfethema, zugewiesener Mitarbeiter (über gleiche E-Mail-Adresse), Zeitstempel. Gelöschte Tickets werden ausgelassen.'],
+ 'threads' => ['Nachrichten', 'Kundennachrichten, Antworten, interne Notizen und Mitlesende'],
+ 'attachments' => ['Anhänge (' . round($counts['attachment_bytes'] / 1048576, 1) . ' MB)', 'Dateien zu den Nachrichten, bis ' . round(OSTICKET_ATTACHMENT_MAX_BYTES / 1048576) . ' MB je Datei. Setzt die Nachrichten voraus.'],
+ ] as $v => [$lb, $desc]): ?>
+
+ style="width:auto;margin-top:2px">
+
+
+
+ Der Import kann gefahrlos wiederholt werden: Vorhandene Kunden und Ticketnummern werden übersprungen, bereits importierte Nachrichten nicht doppelt angelegt. Vorher ein Backup erstellen ist trotzdem empfehlenswert.
+
+
Import starten →
+
Neu hochladen
+
Nur Nachrichten löschen
+
Alle Tickets löschen
+
+
+
+
+
+
+
+
+
Import läuft...
+
Seite nicht schließen.
+ 'Kunden','structure'=>'Abteilungen und Hilfethemen','tickets'=>'Tickets','threads'=>'Nachrichten','attachments'=>'Anhänge'] as $p=>$l): if(!in_array($p,$importWhat)) continue; ?>
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/kb_manage.php b/DeskHub-Webseite/staff/kb_manage.php
new file mode 100644
index 00000000..6df93635
--- /dev/null
+++ b/DeskHub-Webseite/staff/kb_manage.php
@@ -0,0 +1,197 @@
+prepare('INSERT INTO kb_categories (name, slug, sort_order) VALUES (?,?,0)')->execute([$name, $slug]);
+ audit_log($admin['id'], "KB-Kategorie erstellt: {$name}");
+ flash('success', 'Kategorie erstellt.');
+ redirect('/staff/kb_manage.php');
+ }
+ }
+
+ if ($action === 'delete_category') {
+ $id = (int) $_POST['id'];
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE category_id = ?');
+ $stmt->execute([$id]);
+ if ((int) $stmt->fetchColumn() > 0) {
+ flash('error', 'Diese Kategorie enthält noch Artikel und kann nicht gelöscht werden.');
+ } else {
+ $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($admin['id'], "KB-Kategorie gelöscht: {$catName}");
+ flash('success', 'Kategorie gelöscht.');
+ }
+ redirect('/staff/kb_manage.php');
+ }
+
+ if ($action === 'save_article') {
+ $id = $_POST['id'] ?? '';
+ $title = trim($_POST['title'] ?? '');
+ $categoryId = (int) ($_POST['category_id'] ?? 0);
+ $body = sanitize_staff_html($_POST['body'] ?? '');
+ $isPublished = !empty($_POST['is_published']) ? 1 : 0;
+
+ if ($title === '') $errors[] = 'Bitte einen Titel angeben.';
+ if (trim(strip_tags($body)) === '') $errors[] = 'Bitte einen Inhalt angeben.';
+
+ if (!$errors) {
+ if ($id === '') {
+ $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, $body, $isPublished, db_now(), db_now()]);
+ audit_log($admin['id'], "KB-Artikel erstellt: {$title}");
+ flash('success', 'Artikel erstellt.');
+ } else {
+ $pdo->prepare('UPDATE kb_articles SET category_id=?, title=?, body=?, is_published=?, updated_at=? WHERE id=?')
+ ->execute([$categoryId, $title, $body, $isPublished, db_now(), (int) $id]);
+ audit_log($admin['id'], "KB-Artikel aktualisiert: {$title}");
+ flash('success', 'Artikel aktualisiert.');
+ }
+ redirect('/staff/kb_manage.php');
+ }
+ }
+
+ if ($action === 'delete_article') {
+ $id = (int) $_POST['id'];
+ $titleStmt = $pdo->prepare('SELECT title FROM kb_articles WHERE id = ?');
+ $titleStmt->execute([$id]);
+ $artTitle = $titleStmt->fetchColumn();
+ $pdo->prepare('DELETE FROM kb_articles WHERE id = ?')->execute([$id]);
+ audit_log($admin['id'], "KB-Artikel gelöscht: {$artTitle}");
+ flash('success', 'Artikel gelöscht.');
+ redirect('/staff/kb_manage.php');
+ }
+}
+
+$categories = $pdo->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();
+
+$editId = $_GET['edit'] ?? null;
+$editArticle = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM kb_articles WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editArticle = $stmt->fetch();
+}
+$showForm = isset($_GET['new']) || $editArticle;
+
+$pageTitle = 'Wissensdatenbank';
+$activePage = 'kb';
+require __DIR__ . '/_header.php';
+?>
+
+Wissensdatenbank verwalten
+
+
+ = e($err) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Titel Kategorie Status Aufrufe
+
+
+
+ = e($a['title']) ?>
+ = e($a['category_name']) ?>
+ = $a['is_published'] ? 'Live ' : 'Entwurf ' ?>
+ = (int) $a['views'] ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
+
+
Kategorien
+
+
+ = e($c['name']) ?>
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+ = csrf_field() ?>
+
+ Neue Kategorie
+
+ Hinzufügen
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/live_chat.php b/DeskHub-Webseite/staff/live_chat.php
new file mode 100644
index 00000000..f25a2e99
--- /dev/null
+++ b/DeskHub-Webseite/staff/live_chat.php
@@ -0,0 +1,614 @@
+ 0) { @ob_end_flush(); }
+ @flush();
+ }
+ if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
+}
+
+/** JSON-taugliche CSRF-Prüfung für die AJAX-Endpunkte dieser Seite – verify_csrf()
+ * bricht bei Fehlschlag mit einer reinen Textmeldung ab, die fetch().json() nicht
+ * parsen kann. Rotiert den Token nach Verbrauch genauso wie verify_csrf(). */
+function lc_verify_csrf(): void
+{
+ $token = $_POST['csrf_token'] ?? '';
+ if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
+ lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]);
+ }
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+}
+
+[$deptFilterSql, $deptFilterParams] = dept_filter_sql($myDeptIds, 't.department_id');
+
+// Sperre gegen versehentliches gleichzeitiges Antworten zweier Mitarbeiter im selben
+// Chat (auch mit der Mobile-App geteilt, siehe live_chat_locked_by_other() in
+// includes/functions.php): wer den Chat gerade offen hat, "besitzt" ihn für ein paar
+// Minuten. Andere Mitarbeiter können den Chat trotzdem öffnen und live mitlesen
+// (bewusst kein Redirect wie bei der normalen Ticketsperre in ticket_view.php – beim
+// Live-Chat will man durchaus mitschauen können), das Schreiben wird ihnen aber sowohl
+// in der Oberfläche als auch serverseitig (s.u.) verwehrt.
+function lc_locked_by_other(array $ticket, array $staff, PDO $pdo): ?string
+{
+ return live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
+}
+
+// ── AJAX: Nachrichten pollen ────────────────────────────────────────────────
+if (($_GET['action'] ?? '') === 'poll') {
+ $number = $_GET['n'] ?? '';
+ $since = (int) ($_GET['since'] ?? 0);
+ $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
+ $stmt->execute(array_merge([$number], $deptFilterParams));
+ $ticket = $stmt->fetch();
+ if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden.']);
+
+ $msgStmt = $pdo->prepare(
+ 'SELECT tt.id, tt.author_type, tt.message, tt.created_at, 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 AND tt.id > ?
+ ORDER BY tt.created_at ASC, tt.id ASC'
+ );
+ $msgStmt->execute([$ticket['id'], $since]);
+ $lcAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
+ $polledMessages = array_map(function (array $m) use ($lcAttStmt) {
+ $lcAttStmt->execute([$m['id']]);
+ $m['attachments'] = $lcAttStmt->fetchAll();
+ return $m;
+ }, $msgStmt->fetchAll());
+ $customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time();
+ lc_json(['status' => $ticket['status'], 'customerTyping' => $customerTyping, 'messages' => $polledMessages]);
+}
+
+// ── AJAX: "Ich tippe gerade" (für den Tippt-Indikator beim Kunden) ─────────────
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'typing') {
+ // Token wird geprüft, aber bewusst NICHT rotiert (wie im Kunden-Widget, siehe
+ // live_chat.php): feuert bei jedem Tastenanschlag (gedrosselt), eine Rotation würde
+ // sonst mit einem parallel abgeschickten "send" um denselben Token wettlaufen.
+ $submittedCsrf = $_POST['csrf_token'] ?? '';
+ if (!$submittedCsrf || !hash_equals($_SESSION['csrf_token'] ?? '', $submittedCsrf)) {
+ lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]);
+ }
+ $number = $_POST['n'] ?? '';
+ $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
+ $stmt->execute(array_merge([$number], $deptFilterParams));
+ $ticket = $stmt->fetch();
+ if ($ticket && $ticket['status'] !== 'closed' && !lc_locked_by_other($ticket, $staff, $pdo)) {
+ $pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?')
+ ->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
+ }
+ lc_json(['ok' => true]);
+}
+
+// ── AJAX: Chat-Liste pollen (für Live-Aktualisierung + Ton-Benachrichtigung) ────
+if (($_GET['action'] ?? '') === 'list') {
+ $listStmt = $pdo->prepare(
+ "SELECT t.ticket_number, t.requester_name, t.updated_at,
+ (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 last_author,
+ (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 last_message
+ 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);
+ lc_json(['items' => array_map(fn(array $c) => [
+ 'number' => $c['ticket_number'],
+ 'name' => $c['requester_name'],
+ 'preview' => mb_substr(strip_tags((string) $c['last_message']), 0, 60),
+ 'needsReply' => $c['last_author'] === 'customer',
+ 'updatedAt' => $c['updated_at'],
+ ], $listStmt->fetchAll())]);
+}
+
+// ── AJAX: Chat beenden ───────────────────────────────────────────────────────
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'end') {
+ lc_verify_csrf();
+ $number = $_POST['n'] ?? '';
+ $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
+ $stmt->execute(array_merge([$number], $deptFilterParams));
+ $ticket = $stmt->fetch();
+ if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']);
+ if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) {
+ lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]);
+ }
+
+ $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, $ticket['id'], $staff['id'], 'Live-Chat beendet');
+ finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
+
+ // Mitarbeiter nicht auf den SMTP-Versand warten lassen – Antwort sofort raus,
+ // Chatverlauf-Mail danach im Hintergrund (abschaltbar unter Einstellungen →
+ // Allgemein, siehe notify_customer_chat_transcript() in includes/mailer.php).
+ lc_flush_response(['ok' => true]);
+
+ $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());
+ if ($ticket['requester_email']) {
+ try {
+ notify_customer_chat_transcript($ticket, $transcriptMessages);
+ } catch (Throwable $e) {
+ error_log('live_chat transcript mail: ' . $e->getMessage());
+ }
+ }
+ exit;
+}
+
+// ── AJAX: Antwort senden ────────────────────────────────────────────────────
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ lc_verify_csrf();
+ $number = $_POST['n'] ?? '';
+ $message = sanitize_staff_html(trim($_POST['message'] ?? ''));
+
+ $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
+ $stmt->execute(array_merge([$number], $deptFilterParams));
+ $ticket = $stmt->fetch();
+ if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']);
+ if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) {
+ lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]);
+ }
+ if (trim(strip_tags($message)) === '') lc_json(['error' => 'Bitte eine Nachricht eingeben.']);
+
+ $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'] === '');
+ $newStatus = 'answered';
+ $updateFirstResponse = $ticket['first_responded_at'] === null;
+ $stmt = $pdo->prepare(
+ 'UPDATE tickets SET status = ?, updated_at = ?' .
+ ($updateFirstResponse ? ', first_responded_at = ?' : '') .
+ ($autoAssign ? ', assigned_staff_id = ?' : '') .
+ ' WHERE id = ?'
+ );
+ $args = [$newStatus, $now];
+ if ($updateFirstResponse) $args[] = $now;
+ if ($autoAssign) $args[] = $staff['id'];
+ $args[] = $ticket['id'];
+ $stmt->execute($args);
+ if ($autoAssign) log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (Live-Chat)');
+
+ save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
+ log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat-Antwort gesendet');
+ finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
+
+ lc_json(['ok' => true, 'id' => $threadId, 'createdAt' => $now, 'status' => $newStatus]);
+}
+
+// ── Normale Seite ────────────────────────────────────────────────────────────
+$listSql = "SELECT t.*, d.name AS department_name,
+ (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 last_author,
+ (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 last_message
+ FROM tickets t JOIN departments d ON d.id = t.department_id
+ WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
+ ORDER BY t.updated_at DESC LIMIT 100";
+$listStmt = $pdo->prepare($listSql);
+$listStmt->execute($deptFilterParams);
+$chats = $listStmt->fetchAll();
+
+$activeNumber = $_GET['n'] ?? ($chats[0]['ticket_number'] ?? null);
+$activeTicket = null;
+$activeMessages = [];
+if ($activeNumber) {
+ foreach ($chats as $c) {
+ if ($c['ticket_number'] === $activeNumber) { $activeTicket = $c; break; }
+ }
+ if (!$activeTicket) {
+ // Ticket ggf. außerhalb der aktiven Liste (z.B. gerade geschlossen) – direkt laden
+ $stmt = $pdo->prepare('SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id=t.department_id WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
+ $stmt->execute(array_merge([$activeNumber], $deptFilterParams));
+ $activeTicket = $stmt->fetch() ?: null;
+ }
+ if ($activeTicket) {
+ $msgStmt = $pdo->prepare(
+ 'SELECT tt.id, tt.author_type, tt.message, tt.created_at, 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'
+ );
+ $msgStmt->execute([$activeTicket['id']]);
+ $activeMessages = $msgStmt->fetchAll();
+ $lcInitAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
+ foreach ($activeMessages as &$__m) {
+ $lcInitAttStmt->execute([$__m['id']]);
+ $__m['attachments'] = $lcInitAttStmt->fetchAll();
+ }
+ unset($__m);
+ }
+}
+
+// Sperre übernehmen/auffrischen bzw. feststellen, dass ein anderer Mitarbeiter gerade
+// aktiv dran ist (siehe lc_locked_by_other() oben).
+$lockedByOtherName = null;
+if ($activeTicket && $activeTicket['status'] !== 'closed') {
+ $lockedByOtherName = lc_locked_by_other($activeTicket, $staff, $pdo);
+ if (!$lockedByOtherName) {
+ live_chat_acquire_lock((int) $activeTicket['id'], (int) $staff['id'], $pdo);
+ }
+}
+
+$pageTitle = 'Live-Chat';
+$activePage = 'live_chat';
+require __DIR__ . '/_header.php';
+?>
+
+
+ 💬 Live-Chat
+
+
+
+
+
+
+
+
Aktive Live-Chats (= count($chats) ?>)
+
+
+
+
+
+
+
+
Kein Live-Chat ausgewählt
+
+
+
+
+
= e($activeTicket['requester_name']) ?>
+
= e($activeTicket['requester_email']) ?> · = e($activeTicket['department_name']) ?>
+
+
+
+
+
+ 🔒 Wird gerade von = e($lockedByOtherName) ?> bearbeitet – Sie sehen live mit, können hier aber nicht schreiben.
+
+
+
+
Kunde tippt …
+
+
Dieser Chat wurde geschlossen.
+
+
Nur Lesezugriff, solange = e($lockedByOtherName) ?> diesen Chat bearbeitet.
+
+
+
+ Senden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/login.php b/DeskHub-Webseite/staff/login.php
new file mode 100644
index 00000000..55490eb0
--- /dev/null
+++ b/DeskHub-Webseite/staff/login.php
@@ -0,0 +1,148 @@
+prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
+ $stmt->execute([$pendingId]);
+ $staff = $stmt->fetch();
+ if ($staff && totp_verify($staff['totp_secret'], $code)) {
+ unset($_SESSION['totp_pending_staff_id']);
+ staff_login($staff);
+ redirect('/staff/reports.php');
+ }
+ $errors[] = 'Ungültiger Code. Bitte erneut versuchen.';
+ $need2fa = true;
+ }
+ } else {
+ $email = trim($_POST['email'] ?? '');
+ $pass = $_POST['password'] ?? '';
+ if (!rate_limit_check('staff_login_attempt', client_ip(), 10, 15)) {
+ $errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
+ } elseif ($email && !rate_limit_check('staff_login_email', $email, 5, 15)) {
+ // Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs
+ $errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
+ } else {
+ $stmt = get_db()->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
+ $stmt->execute([$email]);
+ $staff = $stmt->fetch();
+ if ($staff && password_verify($pass, $staff['password_hash'])) {
+ // Erfolgreicher Login – Zähler zurücksetzen
+ rate_limit_reset('staff_login_email', $email);
+ if ($staff['totp_enabled']) {
+ $_SESSION['totp_pending_staff_id'] = $staff['id'];
+ $need2fa = true;
+ } else {
+ staff_login($staff);
+ redirect('/staff/reports.php');
+ }
+ } else {
+ $errors[] = 'E-Mail-Adresse oder Passwort ist falsch.';
+ }
+ }
+ }
+}
+
+$pageTitle = 'Mitarbeiter-Login';
+$activePage = '';
+// Staff-Header hat Sidebar – für Login-Seite eigenes schlankes HTML
+$banner = get_setting('banner_path');
+$logo = get_setting('logo_path');
+$displayName = get_setting('display_name', config()['site']['name']);
+?>
+
+
+
+
+
+Mitarbeiter-Login – = e($displayName) ?>
+ ">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Mitarbeiterbereich
+
Anmeldung für Support-Mitarbeiter und Administratoren.
+
+
+
= e($err) ?>
+
+
+
= e($f['message']) ?>
+
+
+
+
Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.
+
+ = csrf_field() ?>
+
+ Authenticator-Code
+
+
+ Verifizieren
+
+
+
← Zurück zum Login
+
+
+ = csrf_field() ?>
+
+ E-Mail-Adresse
+
+ Passwort
+
+
+
+ Anmelden
+
+
+
+
+
+
+
+
+ Copyright © = date('Y') ?> = e($displayName) ?> – Alle Rechte vorbehalten.
+
+
+
+
diff --git a/DeskHub-Webseite/staff/logout.php b/DeskHub-Webseite/staff/logout.php
new file mode 100644
index 00000000..5897b22b
--- /dev/null
+++ b/DeskHub-Webseite/staff/logout.php
@@ -0,0 +1,4 @@
+prepare('UPDATE staff_mentions SET is_read=1 WHERE mentioned_staff_id=?')->execute([$staff['id']]);
+
+$stmt = $pdo->prepare(
+ "SELECT sm.*, t.ticket_number, t.subject, s.name AS by_name, sm.created_at
+ FROM staff_mentions sm
+ JOIN tickets t ON t.id = sm.ticket_id
+ JOIN staff s ON s.id = sm.mentioned_by_staff_id
+ WHERE sm.mentioned_staff_id = ?
+ ORDER BY sm.created_at DESC
+ LIMIT 100"
+);
+$stmt->execute([$staff['id']]);
+$mentions = $stmt->fetchAll();
+
+$pageTitle = 'Meine Erwähnungen';
+$activePage = 'mentions';
+require __DIR__ . '/_header.php';
+?>
+
+
+
🔔 Meine Erwähnungen
+
+
+
+
+
Keine Erwähnungen vorhanden.
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/organizations.php b/DeskHub-Webseite/staff/organizations.php
new file mode 100644
index 00000000..c668c29e
--- /dev/null
+++ b/DeskHub-Webseite/staff/organizations.php
@@ -0,0 +1,142 @@
+prepare('INSERT INTO organizations (name, notes, created_at) VALUES (?,?,?)')
+ ->execute([$name, $notes, db_now()]);
+ audit_log($admin['id'], "Organisation erstellt: {$name}");
+ flash('success', 'Organisation erstellt.');
+ } else {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('UPDATE organizations SET name=?, notes=? WHERE id=?')->execute([$name, $notes, $id]);
+ audit_log($admin['id'], "Organisation aktualisiert: {$name}");
+ flash('success', 'Organisation aktualisiert.');
+ }
+ redirect('/staff/organizations.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('UPDATE users SET organization_id = NULL WHERE organization_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM organizations WHERE id = ?')->execute([$id]);
+ audit_log($admin['id'], "Organisation gelöscht: #{$id}");
+ flash('success', 'Organisation gelöscht.');
+ redirect('/staff/organizations.php');
+ }
+}
+
+$organizations = $pdo->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();
+
+$editId = $_GET['edit'] ?? null;
+$editOrg = null;
+$members = [];
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM organizations WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editOrg = $stmt->fetch();
+ if ($editOrg) {
+ $stmt = $pdo->prepare('SELECT id, name, email FROM users WHERE organization_id = ? ORDER BY name');
+ $stmt->execute([$editId]);
+ $members = $stmt->fetchAll();
+ }
+}
+
+$pageTitle = 'Organisationen';
+$activePage = 'organizations';
+require __DIR__ . '/_header.php';
+?>
+
+Kunden-Organisationen
+Bündeln Sie mehrere Kundenkonten unter einer Firma/Organisation. Kontakte werden unter „Kunden“ zugeordnet.
+
+
+ = e($err) ?>
+
+
+
+
+
+
+ Name Kontakte
+
+
+
+ = e($o['name']) ?>
+ = (int) $o['member_count'] ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+ Noch keine Organisationen angelegt.
+
+
+
+
+
+
+
+
Kontakte von „= e($editOrg['name']) ?>“
+
+
Noch keine Kunden zugeordnet. Das geht unter Kunden .
+
+
+ Name E-Mail
+
+
+ = e($m['name']) ?> = e($m['email']) ?>
+
+
+
+
+
+
+
+
+
+
+
= $editOrg ? 'Organisation bearbeiten' : 'Neue Organisation' ?>
+
+ = csrf_field() ?>
+
+
+ Name
+
+ Notizen (optional)
+ = e($editOrg['notes'] ?? '') ?>
+
+
= $editOrg ? 'Speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/plugin.php b/DeskHub-Webseite/staff/plugin.php
new file mode 100644
index 00000000..b0eaa3c8
--- /dev/null
+++ b/DeskHub-Webseite/staff/plugin.php
@@ -0,0 +1,29 @@
+&page=
+ * Lädt plugins//pages/.php innerhalb des normalen Mitarbeiter-Layouts.
+ */
+require_once __DIR__ . '/../includes/bootstrap.php';
+$staff = require_staff_login();
+$pdo = get_db();
+
+$slug = (string) ($_GET['p'] ?? '');
+$page = (string) ($_GET['page'] ?? '');
+if (!plugin_valid_slug($slug) || !preg_match('/^[A-Za-z0-9_-]+$/', $page)) { http_response_code(404); exit('Seite nicht gefunden.'); }
+
+$loaded = plugin_state()['loaded'][$slug] ?? null;
+$file = plugins_dir() . '/' . $slug . '/pages/' . $page . '.php';
+// Nur Seiten, die das Plugin selbst als Menüpunkt registriert hat (verhindert den Aufruf beliebiger Dateien)
+$registered = array_filter(plugin_state()['menus'], fn($m) => $m['plugin'] === $slug && $m['page'] === $page);
+if (!$loaded || !$registered || !is_file($file)) { http_response_code(404); exit('Seite nicht gefunden oder Plugin nicht aktiv.'); }
+$menu = reset($registered);
+if ($menu['permission'] !== null && !has_permission($staff, $menu['permission'])) { http_response_code(403); exit('Keine Berechtigung.'); }
+
+$pageTitle = $menu['label'];
+$activePage = 'plugin_' . $slug;
+require __DIR__ . '/_header.php';
+plugin_guard($slug, function () use ($file, $staff, $pdo) {
+ // Variablen für die Plugin-Seite: $staff, $pdo
+ include $file;
+});
+require __DIR__ . '/_footer.php';
diff --git a/DeskHub-Webseite/staff/plugins.php b/DeskHub-Webseite/staff/plugins.php
new file mode 100644
index 00000000..0b2923c1
--- /dev/null
+++ b/DeskHub-Webseite/staff/plugins.php
@@ -0,0 +1,241 @@
+ $row) $manifests[$slug] = plugin_read_manifest(plugins_dir() . '/' . $slug)[0];
+$disabled = plugins_globally_disabled();
+$strict = plugin_strict_mode();
+$official = plugin_official_configured();
+$catalog = plugin_catalog_url() !== '' ? plugin_catalog_cached(isset($_GET['refresh'])) : null;
+$hasKey = plugin_publisher_key() !== '';
+$catalogHost = (string) (parse_url(plugin_catalog_url(), PHP_URL_HOST) ?: '');
+
+$pageTitle = 'Plugins';
+$activePage = 'plugins';
+require __DIR__ . '/_header.php';
+?>
+Plugins
+= e($err) ?>
+
+
+ Updates verfügbar: = e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['installed'] . ' → ' . $u['available'], $updates))) ?>. Unten im Katalog mit „Aktualisieren“ installieren.
+
+
+ Alle Plugins sind abgeschaltet (Datei plugins/.disabled oder Einstellung plugins.enabled = false). Bereits aktive Plugins werden nicht geladen.
+
+
+
+
+
+
+
+
Es ist noch keine Katalog-Adresse hinterlegt. Trage unten die Adresse der Plugin-Webseite ein, dann könnt ihr Plugins direkt von dort installieren.
+
+
= e($catalog['error']) ?>
+
+
Stand: = e(date('d.m.Y H:i', (int) $catalog['cached_at'])) ?>
+
+ Plugin Version Signatur
+
+ '); ?>
+
+ = catalog_thumb('plugin', $c) ?>
+
= e($c['name']) ?> = e($c['slug']) ?>
+
= e($c['description']) ?>
+
von = e($c['author']) ?>
+
Fordert an: = e(implode(', ', array_map(fn($p) => $permLabels[$p] ?? $p, $c['permissions']))) ?>
+
+ = e($c['version']) ?>= $have ? 'installiert: ' . e($have['version']) . '
' : '' ?>
+ = $c['signature'] !== '' ? ($hasKey ? '✓ Signiert ' : 'Signatur vorhanden, Schlüssel fehlt ') : 'Nicht signiert ' ?>
+
+ aktuell
+ nicht signiert, nicht installierbar
+
+ = csrf_field() ?>
+ = $newer ? 'Aktualisieren' : 'Installieren' ?>
+
+
+
+
+ Der Katalog enthält keine Plugins.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/priorities.php b/DeskHub-Webseite/staff/priorities.php
new file mode 100644
index 00000000..9c023c20
--- /dev/null
+++ b/DeskHub-Webseite/staff/priorities.php
@@ -0,0 +1,167 @@
+ $resolution) $errors[] = 'Die Reaktionszeit darf nicht länger als die Lösungszeit sein.';
+ if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) $errors[] = 'Bitte eine gültige Farbe im Format #RRGGBB angeben.';
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE LOWER(name) = LOWER(?) AND id <> ?');
+ $dup->execute([$name, $id]);
+ if ($dup->fetchColumn() > 0) $errors[] = 'Eine Priorität mit diesem Namen existiert bereits.';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $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 ?: null, db_now(), db_now()]);
+ audit_log($staff['id'], "Priorität erstellt: {$name}");
+ flash('success', 'Priorität erstellt.');
+ } 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 ?: null, db_now(), $id]);
+ audit_log($staff['id'], "Priorität aktualisiert: {$name}");
+ flash('success', 'Änderungen gespeichert.');
+ }
+ redirect('/staff/priorities.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE priority_id = ?');
+ $stmt->execute([$id]);
+ if ((int) $stmt->fetchColumn() > 0) {
+ flash('error', 'Diese Priorität wird noch von Tickets verwendet und kann nicht gelöscht werden.');
+ } else {
+ $pdo->prepare('DELETE FROM priorities WHERE id = ?')->execute([$id]);
+ audit_log($staff['id'], "Priorität gelöscht: #{$id}");
+ flash('success', 'Priorität gelöscht.');
+ }
+ redirect('/staff/priorities.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$priorities = $pdo->query('SELECT p.*, (SELECT COUNT(*) FROM tickets t WHERE t.priority_id = p.id) AS ticket_count FROM priorities p ORDER BY p.sort_order')->fetchAll();
+$editPrio = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editPrio = $stmt->fetch() ?: null;
+}
+$hours = fn(int $h) => $h >= 24 && $h % 24 === 0 ? ($h / 24) . ' Tag' . ($h / 24 === 1 ? '' : 'e') : $h . ' Std';
+
+$pageTitle = 'Prioritäten / SLA';
+$activePage = 'priorities';
+require __DIR__ . '/_header.php';
+?>
+
+
+ = e($err) ?>
+
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editPrio[$col] ?? $default);
+?>
+
+ = $editPrio ? 'Priorität / SLA aktualisieren' : 'Neue Priorität' ?>
+ — = e($editPrio['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+ 'Priorität', 'sla' => 'SLA-Zeiten']); ?>
+
+
+
+
+
Zeiten in Stunden ab Ticketerstellung bzw. Prioritätsänderung. Sie gelten kalendarisch (rund um die Uhr) – sobald unter Geschäftszeiten „Geschäftszeiten für SLA-Berechnung verwenden“ aktiv ist, zählen nur die dort eingetragenen Öffnungszeiten .
+
+
+
+
+
= $editPrio ? 'Änderungen speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+Reaktions- und Lösungszeit ab Ticketerstellung bzw. Prioritätsänderung. Mit aktivierten Geschäftszeiten zählen nur die dort eingetragenen Öffnungszeiten.
+
+
+
+ Priorität Reaktion Lösung Tickets Erstellt Zuletzt aktualisiert
+
+
+
+ = e($p['name']) ?>
+ = e($hours((int) $p['response_hours'])) ?>
+ = e($hours((int) $p['resolution_hours'])) ?>
+ = (int) $p['ticket_count'] ?>
+ = e(ui_date($p['created_at'] ?? null)) ?>
+ = e(ui_date($p['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/profile.php b/DeskHub-Webseite/staff/profile.php
new file mode 100644
index 00000000..8d672a5f
--- /dev/null
+++ b/DeskHub-Webseite/staff/profile.php
@@ -0,0 +1,509 @@
+prepare('SELECT COUNT(*) FROM staff WHERE email = ? AND id != ?');
+ $dup->execute([$email, $staff['id']]);
+ if ((int)$dup->fetchColumn()) $errors[] = 'Diese E-Mail-Adresse wird bereits von einem anderen Mitarbeiter verwendet.';
+ }
+
+ if (!$errors) {
+ $pdo->prepare('UPDATE staff SET name=?, email=?, phone=? WHERE id=?')
+ ->execute([$name, $email, $phone ?: null, $staff['id']]);
+ audit_log($staff['id'], 'Profil aktualisiert: Name/E-Mail/Telefon');
+ flash('success', 'Kontodaten gespeichert.');
+ redirect('/staff/profile.php?tab=account');
+ }
+ $tab = 'account';
+ }
+
+ // ── Passwort ─────────────────────────────────────────────────
+ if ($action === 'save_password') {
+ $current = $_POST['current_password'] ?? '';
+ $new = $_POST['new_password'] ?? '';
+ $confirm = $_POST['confirm_password'] ?? '';
+
+ if (!password_verify($current, $staff['password_hash'])) {
+ $errors[] = 'Das aktuelle Passwort ist falsch.';
+ }
+ $pwError = validate_password_strength($new);
+ if ($pwError) {
+ $errors[] = $pwError;
+ }
+ if ($new !== $confirm) {
+ $errors[] = 'Die Passwörter stimmen nicht überein.';
+ }
+
+ if (!$errors) {
+ $pdo->prepare('UPDATE staff SET password_hash=? WHERE id=?')
+ ->execute([password_hash($new, PASSWORD_DEFAULT), $staff['id']]);
+ audit_log($staff['id'], 'Passwort geändert');
+ flash('success', 'Passwort geändert.');
+ redirect('/staff/profile.php?tab=account');
+ }
+ $tab = 'account';
+ }
+
+ // ── Avatar ────────────────────────────────────────────────────
+ if ($action === 'save_avatar') {
+ if (!empty($_FILES['avatar']['name'])) {
+ $allowed = ['jpg','jpeg','png','gif','webp'];
+ $ext = strtolower(pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION));
+ if (!in_array($ext, $allowed)) {
+ $errors[] = 'Erlaubte Bildformate: JPG, PNG, GIF, WEBP.';
+ } elseif ($_FILES['avatar']['size'] > 2 * 1024 * 1024) {
+ $errors[] = 'Maximale Dateigröße: 2 MB.';
+ } else {
+ $dir = __DIR__ . '/../assets/avatars/';
+ if (!is_dir($dir)) mkdir($dir, 0755, true);
+ // Alten Avatar löschen
+ if ($staff['avatar_path']) {
+ $old = __DIR__ . '/..' . $staff['avatar_path'];
+ if (file_exists($old)) unlink($old);
+ }
+ $filename = 'avatar_' . $staff['id'] . '_' . time() . '.' . $ext;
+ move_uploaded_file($_FILES['avatar']['tmp_name'], $dir . $filename);
+ $path = '/assets/avatars/' . $filename;
+ $pdo->prepare('UPDATE staff SET avatar_path=? WHERE id=?')->execute([$path, $staff['id']]);
+ flash('success', 'Avatar aktualisiert.');
+ redirect('/staff/profile.php?tab=account');
+ }
+ }
+ $tab = 'account';
+ }
+
+ if ($action === 'delete_avatar') {
+ if ($staff['avatar_path']) {
+ $old = __DIR__ . '/..' . $staff['avatar_path'];
+ if (file_exists($old)) unlink($old);
+ }
+ $pdo->prepare('UPDATE staff SET avatar_path=NULL WHERE id=?')->execute([$staff['id']]);
+ flash('success', 'Avatar entfernt.');
+ redirect('/staff/profile.php?tab=account');
+ }
+
+ // ── Urlaub/Abwesenheit ─────────────────────────────────────
+ if ($action === 'save_settings') {
+ $onVacation = !empty($_POST['is_on_vacation']) ? 1 : 0;
+ $substituteId = ($_POST['vacation_substitute_id'] ?? '') !== '' ? (int)$_POST['vacation_substitute_id'] : null;
+ $pdo->prepare('UPDATE staff SET is_on_vacation=?, vacation_substitute_id=? WHERE id=?')
+ ->execute([$onVacation, $substituteId, $staff['id']]);
+ audit_log($staff['id'], $onVacation ? 'Abwesenheit aktiviert' : 'Abwesenheit deaktiviert');
+ flash('success', 'Einstellungen gespeichert.');
+ redirect('/staff/profile.php?tab=settings');
+ }
+
+ // ── 2FA aktivieren ─────────────────────────────────────────────
+ if ($action === 'enable_2fa') {
+ $code = trim($_POST['totp_code'] ?? '');
+ $secret = $_SESSION['totp_setup_secret'] ?? '';
+ if (!$secret) {
+ flash('error', 'Sitzungsfehler. Bitte nochmals versuchen.');
+ } elseif (!totp_verify($secret, $code)) {
+ flash('error', 'Code ungültig. Bitte erneut versuchen.');
+ } else {
+ $pdo->prepare('UPDATE staff SET totp_secret=?, totp_enabled=1 WHERE id=?')->execute([$secret, $staff['id']]);
+ unset($_SESSION['totp_setup_secret']);
+ audit_log($staff['id'], 'Zwei-Faktor-Authentifizierung aktiviert');
+ flash('success', '2FA wurde erfolgreich aktiviert.');
+ }
+ redirect('/staff/profile.php?tab=security');
+ }
+
+ // ── 2FA deaktivieren ───────────────────────────────────────────
+ if ($action === 'disable_2fa') {
+ $pass = $_POST['confirm_password'] ?? '';
+ $me = $pdo->prepare('SELECT password_hash FROM staff WHERE id=?');
+ $me->execute([$staff['id']]);
+ $me = $me->fetch();
+ if (!password_verify($pass, $me['password_hash'])) {
+ flash('error', 'Falsches Passwort.');
+ } else {
+ $pdo->prepare('UPDATE staff SET totp_secret=NULL, totp_enabled=0 WHERE id=?')->execute([$staff['id']]);
+ audit_log($staff['id'], 'Zwei-Faktor-Authentifizierung deaktiviert');
+ flash('success', '2FA wurde deaktiviert.');
+ }
+ redirect('/staff/profile.php?tab=security');
+ }
+
+ // ── Signatur ──────────────────────────────────────────────────
+ if ($action === 'save_signature') {
+ $sig = sanitize_staff_html(trim($_POST['signature'] ?? ''));
+ $pdo->prepare('UPDATE staff SET signature=? WHERE id=?')->execute([$sig ?: null, $staff['id']]);
+ flash('success', 'Signatur gespeichert.');
+ redirect('/staff/profile.php?tab=signature');
+ }
+}
+
+// Aktuellen Stand neu laden (nach Redirect relevant wenn $_SESSION cached)
+$staffRow = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
+$staffRow->execute([$staff['id']]);
+$me = $staffRow->fetch();
+
+$pageTitle = 'Mein Profil';
+$activePage = 'profile';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
Mein Profil
+
+
+
= e($err) ?>
+
+
+
+
+ '👤 Konto', 'settings' => '⚙ Einstellungen', 'signature' => '✍ Signatur', 'security' => '🔒 Sicherheit'] as $t => $label): ?>
+
+ = $label ?>
+
+
+
+
+
+
+
+
+
Profilbild
+
+
+
+
+
+
+ = mb_strtoupper(mb_substr($me['name'], 0, 1)) ?>
+
+
+
+
+
+ = csrf_field() ?>
+
+ Neues Bild hochladen (JPG, PNG, WEBP · max. 2 MB)
+
+
+ Hochladen
+
+
+
+
+ = csrf_field() ?>
+
+ Bild entfernen
+
+
+
+
+
+
+
+
+
+
Passwort ändern
+
+ = csrf_field() ?>
+
+ Aktuelles Passwort
+
+
+
+ Passwort ändern
+
+
+
+
+
+
+
+
Browser-Push-Benachrichtigungen
+
+
+ Erhalte Desktop-Benachrichtigungen bei neuen Tickets und Antworten — auch wenn der Browser-Tab nicht aktiv ist.
+ Funktioniert in Chrome, Firefox und Edge. Erfordert HTTPS.
+
+
+
🔕
+
+
Deaktiviert
+
Klicken Sie auf Aktivieren um Benachrichtigungen zu erhalten
+
+
+ Aktivieren
+
+
+
+
Benachrichtigungen erhalten bei:
+
+
✅ Neues Ticket in meiner Abteilung
+
✅ Neue Antwort auf mir zugewiesenes Ticket
+
+
+
+
+
+ Push-Benachrichtigungen sind auf diesem Server nicht verfügbar (OpenSSL-Erweiterung fehlt oder PHP zu alt).
+
+
+
+
+
+
+
+
+
Abwesenheit / Urlaub
+
+ = csrf_field() ?>
+
+ query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
+ ?>
+
+ >
+
+ Urlaubsmodus aktiv
+ Neue Tickets werden nicht automatisch diesem Konto zugewiesen, während der Urlaubsmodus aktiv ist. Bereits zugewiesene Tickets bleiben unverändert.
+
+
+ Vertretung (optional)
+
+ – keine Vertretung –
+
+ >= e($a['name']) ?>
+
+
+ Neue Tickets werden während Ihrer Abwesenheit automatisch dieser Person zugewiesen.
+
+ Urlaubsmodus ist aktuell aktiv . Sie erhalten keine automatischen Ticket-Zuweisungen.
+
+
+ Speichern
+
+
+
+
+
+
Übersicht
+
+
+ Rolle
+ = $me['role'] === 'admin' ? 'Administrator ' : 'Mitarbeiter ' ?>
+
+
+ Mitglied seit
+ = format_dt($me['created_at']) ?>
+
+ prepare('SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ?');
+ $ticketCount->execute([$me['id']]);
+ $myCount = $ticketCount->fetchColumn();
+ ?>
+
+
+
+
+
+
+
+
+
E-Mail-Signatur
+
+ Wird automatisch an Ihre ausgehenden Ticket-Antworten angehängt.
+ Ist keine Mitarbeiter-Signatur gesetzt, greift die Abteilungs-Signatur.
+
+
+ = csrf_field() ?>
+
+
+
+ B
+ I
+ U
+ •
+ 🔗
+
+
= $me['signature'] ?? '' ?>
+
+
+
+ Speichern
+ Löschen
+
+
+
+
+
Vorschau:
+
+ = $me['signature'] ?>
+
+
+
+
+
+
+
+
+prepare('SELECT totp_enabled, totp_secret, email FROM staff WHERE id=?');
+$me2->execute([$staff['id']]);
+$me2 = $me2->fetch();
+if (!$me2['totp_enabled'] && empty($_SESSION['totp_setup_secret'])) {
+ $_SESSION['totp_setup_secret'] = totp_generate_secret();
+}
+$setupSecret = $_SESSION['totp_setup_secret'] ?? '';
+?>
+
+
🔒 Zwei-Faktor-Authentifizierung (2FA)
+
+
2FA ist aktuell aktiv .
+
Um 2FA zu deaktivieren, bestätigen Sie mit Ihrem Passwort:
+
+ = csrf_field() ?>
+
+
+
+ 2FA deaktivieren
+
+
+
+
Schützen Sie Ihr Konto mit einem zeitbasierten Einmalcode (TOTP – kompatibel mit Google Authenticator, Authy, etc.).
+
+ Installieren Sie Google Authenticator oder Authy
+ Scannen Sie den QR-Code oder geben Sie den Schlüssel manuell ein
+ Tragen Sie den 6-stelligen Code ein und klicken Sie „2FA aktivieren"
+
+
+
+
+
Manueller Schlüssel:
+
= e(chunk_split($setupSecret, 4, ' ')) ?>
+
+
+
+ = csrf_field() ?>
+
+ Code aus der App
+
+ 2FA aktivieren
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/push_subscribe.php b/DeskHub-Webseite/staff/push_subscribe.php
new file mode 100644
index 00000000..df8f0fb0
--- /dev/null
+++ b/DeskHub-Webseite/staff/push_subscribe.php
@@ -0,0 +1,47 @@
+ 'POST only']); exit;
+}
+
+$body = json_decode(file_get_contents('php://input'), true);
+$action = $body['action'] ?? 'subscribe';
+
+if ($action === 'unsubscribe') {
+ $endpoint = $body['endpoint'] ?? '';
+ if ($endpoint) {
+ $pdo->prepare('DELETE FROM push_subscriptions WHERE staff_id = ? AND endpoint = ?')
+ ->execute([$staff['id'], $endpoint]);
+ }
+ echo json_encode(['ok' => true]);
+ exit;
+}
+
+// Subscribe
+$endpoint = $body['endpoint'] ?? '';
+$p256dh = $body['keys']['p256dh'] ?? '';
+$auth = $body['keys']['auth'] ?? '';
+
+if (!$endpoint || !$p256dh || !$auth) {
+ http_response_code(400); echo json_encode(['error' => 'Fehlende Felder']); exit;
+}
+
+// Duplicate check
+$chk = $pdo->prepare('SELECT id FROM push_subscriptions WHERE endpoint = ?');
+$chk->execute([$endpoint]);
+if (!$chk->fetchColumn()) {
+ $pdo->prepare(
+ 'INSERT INTO push_subscriptions (staff_id, endpoint, p256dh, auth, created_at) VALUES (?,?,?,?,?)'
+ )->execute([$staff['id'], $endpoint, $p256dh, $auth, db_now()]);
+}
+
+echo json_encode(['ok' => true]);
diff --git a/DeskHub-Webseite/staff/queues.php b/DeskHub-Webseite/staff/queues.php
new file mode 100644
index 00000000..8e9deeb0
--- /dev/null
+++ b/DeskHub-Webseite/staff/queues.php
@@ -0,0 +1,181 @@
+prepare('UPDATE saved_queues SET name=?, updated_at=? WHERE id=? AND (staff_id=? OR ?=1)')
+ ->execute([$name, db_now(), $id, $staff['id'], $staff['role'] === 'admin' ? 1 : 0]);
+ flash('success', 'Queue umbenannt.');
+ }
+ redirect('/staff/queues.php');
+ }
+
+ if ($action === 'delete') {
+ $id = (int)($_POST['id'] ?? 0);
+ if ($id) {
+ $pdo->prepare('DELETE FROM saved_queues WHERE id=? AND (staff_id=? OR ?=1)')
+ ->execute([$id, $staff['id'], $staff['role'] === 'admin' ? 1 : 0]);
+ flash('success', 'Queue gelöscht.');
+ }
+ redirect('/staff/queues.php');
+ }
+
+ if ($action === 'reorder') {
+ $ids = array_map('intval', $_POST['ids'] ?? []);
+ foreach ($ids as $i => $id) {
+ $pdo->prepare('UPDATE saved_queues SET sort_order=? WHERE id=? AND staff_id=?')
+ ->execute([$i, $id, $staff['id']]);
+ }
+ echo json_encode(['ok' => true]);
+ exit;
+ }
+}
+
+$queues = queues_for($pdo, (int) $staff['id']);
+
+$pageTitle = 'Gespeicherte Ansichten';
+$activePage = 'tickets';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+
+
+
+
Noch keine gespeicherten Ansichten vorhanden.
+
Filter in der Ticket-Liste setzen und auf „Ansicht speichern" klicken.
+
+
+
+
+
Meine Ansichten
+
Reihenfolge per Drag & Drop ändern. Klick auf den Namen öffnet die Ansicht.
+
+
+
+
+
+
+
+
Ansicht umbenennen
+
+ = csrf_field() ?>
+
+
+ Name
+
+
+ Speichern
+ Abbrechen
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/recurring_tickets.php b/DeskHub-Webseite/staff/recurring_tickets.php
new file mode 100644
index 00000000..ebbfcfeb
--- /dev/null
+++ b/DeskHub-Webseite/staff/recurring_tickets.php
@@ -0,0 +1,234 @@
+query('SELECT department_id FROM recurring_tickets')->fetchAll(PDO::FETCH_COLUMN)));
+$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
+$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $subject = trim($_POST['subject'] ?? '');
+ $message = trim($_POST['message'] ?? '');
+ $deptId = (int) ($_POST['department_id'] ?? 0);
+ $prioId = (int) ($_POST['priority_id'] ?? 0);
+ $assignTo = ($_POST['assigned_staff_id'] ?? '') !== '' ? (int)$_POST['assigned_staff_id'] : null;
+ $days = max(1, (int) ($_POST['interval_days'] ?? 30));
+ $nextRun = trim($_POST['next_run'] ?? date('Y-m-d'));
+ $isActive = !empty($_POST['is_active']) ? 1 : 0;
+
+ if ($subject === '') $errors[] = 'Bitte einen Betreff angeben.';
+ if ($message === '') $errors[] = 'Bitte eine Nachricht eingeben.';
+ if (!$deptId) $errors[] = 'Bitte eine Abteilung wählen.';
+ if (!$prioId) $errors[] = 'Bitte eine Priorität wählen.';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $pdo->prepare('INSERT INTO recurring_tickets (subject,message,department_id,priority_id,assigned_staff_id,interval_days,next_run,is_active,created_by,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
+ ->execute([$subject, $message, $deptId, $prioId, $assignTo, $days, $nextRun, $isActive, $staff['id'], db_now()]);
+ audit_log($staff['id'], "Wiederkehrendes Ticket erstellt: {$subject}");
+ flash('success', 'Wiederkehrendes Ticket erstellt.');
+ } else {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('UPDATE recurring_tickets SET subject=?,message=?,department_id=?,priority_id=?,assigned_staff_id=?,interval_days=?,next_run=?,is_active=? WHERE id=?')
+ ->execute([$subject, $message, $deptId, $prioId, $assignTo, $days, $nextRun, $isActive, $id]);
+ audit_log($staff['id'], "Wiederkehrendes Ticket bearbeitet: {$subject}");
+ flash('success', 'Gespeichert.');
+ }
+ redirect('/staff/recurring_tickets.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('DELETE FROM recurring_tickets WHERE id=?')->execute([$id]);
+ audit_log($staff['id'], 'Wiederkehrendes Ticket gelöscht');
+ flash('success', 'Gelöscht.');
+ redirect('/staff/recurring_tickets.php');
+ }
+
+ if ($action === 'toggle') {
+ $id = (int) $_POST['id'];
+ $pdo->prepare('UPDATE recurring_tickets SET is_active = 1 - is_active WHERE id=?')->execute([$id]);
+ redirect('/staff/recurring_tickets.php');
+ }
+
+ if ($action === 'run_now') {
+ $id = (int) $_POST['id'];
+ $rt = $pdo->prepare('SELECT * FROM recurring_tickets WHERE id=?');
+ $rt->execute([$id]); $rt = $rt->fetch();
+ if ($rt) {
+ $tn = generate_ticket_number();
+ $now = db_now();
+ $nextRun = (new DateTimeImmutable('today'))
+ ->modify('+' . (int) $rt['interval_days'] . ' days')
+ ->format('Y-m-d');
+ try {
+ $pdo->beginTransaction();
+ $pdo->prepare(
+ 'INSERT INTO tickets (ticket_number,subject,requester_name,requester_email,department_id,priority_id,assigned_staff_id,status,source,created_at,updated_at)
+ VALUES (?,?,?,?,?,?,?,\'open\',\'staff\',?,?)'
+ )->execute([$tn, $rt['subject'], 'System', 'system@recurring', $rt['department_id'], $rt['priority_id'], $rt['assigned_staff_id'], $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 (?,\'staff\',?,?,0,?)')
+ ->execute([$ticketId, $staff['id'], $rt['message'], $now]);
+ $pdo->prepare('UPDATE recurring_tickets SET next_run = ? WHERE id = ?')
+ ->execute([$nextRun, $id]);
+ $pdo->commit();
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $e;
+ }
+ flash('success', "Ticket #{$tn} sofort erstellt.");
+ }
+ redirect('/staff/recurring_tickets.php');
+ }
+}
+
+$items = $pdo->query(
+ 'SELECT rt.*, d.name AS dept_name, p.name AS prio_name, s.name AS agent_name
+ FROM recurring_tickets rt
+ JOIN departments d ON d.id=rt.department_id
+ JOIN priorities p ON p.id=rt.priority_id
+ LEFT JOIN staff s ON s.id=rt.assigned_staff_id
+ ORDER BY rt.next_run ASC'
+)->fetchAll();
+
+$editing = null;
+if (!empty($_GET['edit'])) {
+ $stmt = $pdo->prepare('SELECT * FROM recurring_tickets WHERE id=?');
+ $stmt->execute([(int)$_GET['edit']]);
+ $editing = $stmt->fetch();
+}
+
+$pageTitle = 'Wiederkehrende Tickets';
+$activePage = 'recurring';
+require __DIR__ . '/_header.php';
+?>
+
+
+
Wiederkehrende Tickets
+
+
+Tickets die automatisch nach einem festen Intervall neu erstellt werden (z.B. monatliche Wartung). Erstellung per Cronjob: php cron/process_recurring.php
+
+= e($e) ?>
+
+
+
+
= $editing ? 'Muster bearbeiten' : 'Neues Muster' ?>
+
+ = csrf_field() ?>
+
+
+
+ Betreff
+
+
+ Nachricht (erster Thread-Eintrag)
+ = e($editing['message'] ?? '') ?>
+
+
+
+ Abteilung
+
+
+ >= e($d['name']) ?>
+
+
+
+
+ Priorität
+
+
+ >= e($p['name']) ?>
+
+
+
+
+
+
+
+ Zugewiesen an (optional)
+
+ – nicht zugewiesen –
+
+ >= e($a['name']) ?>
+
+
+
+
+ Intervall (Tage)
+
+
+
+ Nächste Ausführung
+
+
+
+
+
+ >
+ Aktiv
+
+
+
+
+
+
+
+
+
+
Noch keine Muster angelegt.
+
+
+ Betreff Abteilung Intervall Nächste Ausführung Status
+
+
+
+ = e($rt['subject']) ?> = e($rt['agent_name'] ?? '–') ?>
+ = e($rt['dept_name']) ?>
+ Alle = $rt['interval_days'] ?> Tage
+ = e($rt['next_run']) ?>
+ = $rt['is_active'] ? 'Aktiv' : 'Pausiert' ?>
+
+ Bearbeiten
+
+ = csrf_field() ?>
+
+
+ = $rt['is_active'] ? 'Pausieren' : 'Aktivieren' ?>
+
+
+ = csrf_field() ?>
+
+
+ ▶ Jetzt
+
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/report_builder.php b/DeskHub-Webseite/staff/report_builder.php
new file mode 100644
index 00000000..55e7029d
--- /dev/null
+++ b/DeskHub-Webseite/staff/report_builder.php
@@ -0,0 +1,178 @@
+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($_POST['shared']) ? 1 : 0, $now, $now]);
+ flash('success', 'Bericht gespeichert.');
+ }
+ redirect('/staff/report_builder.php?' . ltrim((string) ($_POST['return_query'] ?? ''), '?'));
+ }
+ if ($action === 'delete') {
+ $row = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
+ $row->execute([(int) $_POST['id']]);
+ if (($r = $row->fetch()) && ((int) $r['staff_id'] === (int) $staff['id'] || $staff['role'] === 'admin')) {
+ $pdo->prepare('DELETE FROM saved_reports WHERE id = ?')->execute([$r['id']]);
+ flash('success', 'Bericht gelöscht.');
+ }
+ redirect('/staff/report_builder.php');
+ }
+}
+
+// ── Konfiguration: aus gespeichertem Bericht oder aus der URL ───────────────
+$saved = $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');
+$saved->execute([$staff['id']]);
+$savedReports = $saved->fetchAll();
+$cfgIn = $_GET;
+if (isset($_GET['saved'])) {
+ foreach ($savedReports as $sr) {
+ if ((int) $sr['id'] === (int) $_GET['saved']) { $cfgIn = json_decode($sr['config'], true) ?: []; break; }
+ }
+}
+$cfg = report_clean_config($pdo, $cfgIn);
+$report = report_run($pdo, $cfg, $scope);
+
+$queryOf = fn(array $c) => http_build_query([
+ 'dimension' => $c['dimension'], 'from' => $c['from'], 'to' => $c['to'], 'sort' => $c['sort'], 'desc' => $c['desc'] ? 1 : 0,
+ 'metrics' => $c['metrics'], 'filters' => $c['filters'],
+]);
+
+if (($_GET['export'] ?? '') === 'csv') {
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename="bericht-' . $cfg['dimension'] . '-' . $cfg['from'] . '_' . $cfg['to'] . '.csv"');
+ echo report_to_csv($report);
+ exit;
+}
+
+$departments = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
+$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
+$topics = help_topic_options($pdo, false, false);
+$fmtValue = function ($v, string $unit) {
+ if ($v === null) return '–';
+ return ($unit === '%' || $unit === 'Std' ? number_format((float) $v, 1, ',', '.') : (string) $v) . ($unit === '%' ? ' %' : ($unit === 'Std' ? ' Std' : ''));
+};
+$maxByMetric = [];
+foreach (array_slice($report['columns'], 1) as $c) $maxByMetric[$c['key']] = max(1, ...array_map(fn($r) => (float) ($r[$c['key']] ?? 0), $report['rows'] ?: [[$c['key'] => 0]]));
+
+$pageTitle = 'Bericht-Baukasten';
+$activePage = 'report_builder';
+require __DIR__ . '/_header.php';
+?>
+
+
+Wähle Gruppierung, Kennzahlen, Zeitraum und Filter – nach dem Erstellungsdatum der Tickets. Berichte lassen sich speichern und mit allen teilen.
+
+
+
+
+
+
+
+
Gruppieren nach
+ $d): ?>>= e($d[0]) ?>
+
Von
+
Bis
+
Abteilung
+ Alle >= e($d['name']) ?>
+
Priorität
+ Alle >= e($p['name']) ?>
+
Mitarbeiter
+ Alle >= e($a['name']) ?>
+
Hilfethema
+ Alle >= e($t['path']) ?>
+
Quelle
+ Alle 'Web', 'email' => 'E-Mail', 'phone' => 'Telefon', 'staff' => 'Mitarbeiter', 'chat' => 'Live-Chat', 'api' => 'API'] as $k => $l): ?>>= $l ?>
+
+ Kennzahlen
+
+ $m): ?>
+ > = e($m[0]) ?>
+
+
+
+
Sortieren nach
+ >Gruppe $m): ?>>= e($m[0]) ?>
+
> absteigend
+
Bericht erstellen
+
+
+
+
+
+ = e($c['label']) ?>
+
+
+
+ = e((string) $row['label']) ?>
+
+
+ = e($fmtValue($v, $c['unit'])) ?>
+
+
+
+
+
+ Keine Tickets im gewählten Zeitraum.
+ Gesamt
+ = e($fmtValue($report['totals'][$c['key']] ?? null, $c['unit'])) ?>= $c['unit'] !== '' ? ' Ø ' : '' ?>
+
+
+
+
+
+
+ Diesen Bericht speichern
+
+ = csrf_field() ?>
+
+
+ Name
+
+ Für alle Mitarbeiter sichtbar
+ Speichern
+
+
+
+
diff --git a/DeskHub-Webseite/staff/reports.php b/DeskHub-Webseite/staff/reports.php
new file mode 100644
index 00000000..798f1d25
--- /dev/null
+++ b/DeskHub-Webseite/staff/reports.php
@@ -0,0 +1,758 @@
+Keine Daten vorhanden. ';
+ $palette = ['#0f6f6a', '#2563eb', '#d97706', '#dc2626', '#a855f7', '#16a34a', '#6b7280'];
+ $max = max(max($data), 1);
+ $count = count($data);
+ $barWidth = 38;
+ $gap = 16;
+ $width = $count * ($barWidth + $gap) + $gap;
+ $chartHeight = $height - 46;
+
+ $svg = "";
+ $x = $gap; $i = 0;
+ foreach ($data as $label => $value) {
+ $barH = (int) round(($value / $max) * $chartHeight);
+ $y = $chartHeight - $barH + 18;
+ $color = $palette[$i % count($palette)];
+ $svg .= " ";
+ $svg .= "" . (int) $value . " ";
+ $svg .= "" . htmlspecialchars((string) $label) . " ";
+ $x += $barWidth + $gap;
+ $i++;
+ }
+ $svg .= ' ';
+ return $svg;
+}
+
+/** Mehrlinien-Diagramm als SVG. $buckets: [label => [serieKey => wert]]. $series: [serieKey => ['label'=>..,'color'=>..]] */
+function svg_multiline_chart(array $buckets, array $series): string
+{
+ if (!$buckets) return 'Keine Daten im gewählten Zeitraum.
';
+ $labels = array_keys($buckets);
+ $n = count($labels);
+ $width = max(640, $n * 48);
+ $height = 260;
+ $padLeft = 30; $padBottom = 36; $padTop = 16; $padRight = 16;
+ $chartW = $width - $padLeft - $padRight;
+ $chartH = $height - $padTop - $padBottom;
+
+ $maxVal = 1;
+ foreach ($buckets as $b) {
+ foreach (array_keys($series) as $key) {
+ $maxVal = max($maxVal, (int) ($b[$key] ?? 0));
+ }
+ }
+
+ $svg = "";
+ $svg .= " ";
+
+ $stepX = $n > 1 ? $chartW / ($n - 1) : 0;
+
+ foreach ($series as $key => $info) {
+ $points = [];
+ foreach ($labels as $i => $label) {
+ $val = (int) ($buckets[$label][$key] ?? 0);
+ $x = round($padLeft + $i * $stepX, 1);
+ $y = round($padTop + $chartH - ($val / $maxVal) * $chartH, 1);
+ $points[] = "{$x},{$y}";
+ }
+ $svg .= ' ';
+ foreach ($labels as $i => $label) {
+ $val = (int) ($buckets[$label][$key] ?? 0);
+ $x = round($padLeft + $i * $stepX, 1);
+ $y = round($padTop + $chartH - ($val / $maxVal) * $chartH, 1);
+ $svg .= " ";
+ }
+ }
+
+ $labelEvery = max(1, (int) ceil($n / 12));
+ foreach ($labels as $i => $label) {
+ if ($i % $labelEvery !== 0 && $i !== $n - 1) continue;
+ $x = round($padLeft + $i * $stepX, 1);
+ $svg .= "" . htmlspecialchars((string) $label) . " ";
+ }
+ $svg .= ' ';
+ return $svg;
+}
+
+// ---------------------------------------------------------------
+// Zeitraum bestimmen
+// ---------------------------------------------------------------
+$preset = $_GET['range'] ?? '30d';
+$today = new DateTime('today');
+switch ($preset) {
+ case '7d': $from = (clone $today)->modify('-6 days'); $to = clone $today; break;
+ case 'thismonth': $from = new DateTime(date('Y-m-01')); $to = clone $today; break;
+ case 'thisyear': $from = new DateTime(date('Y-01-01')); $to = clone $today; break;
+ case 'custom':
+ $from = !empty($_GET['from']) ? new DateTime($_GET['from']) : (clone $today)->modify('-29 days');
+ $to = !empty($_GET['to']) ? new DateTime($_GET['to']) : clone $today;
+ break;
+ case '30d':
+ default:
+ $preset = '30d';
+ $from = (clone $today)->modify('-29 days'); $to = clone $today;
+}
+if ($from > $to) { [$from, $to] = [$to, $from]; }
+$rangeFrom = $from->format('Y-m-d 00:00:00');
+$rangeTo = $to->format('Y-m-d 23:59:59');
+$rangeDays = (int) $from->diff($to)->days + 1;
+$useWeekly = $rangeDays > 45;
+
+// ---------------------------------------------------------------
+// Kennzahlen (gesamt, nicht zeitraumgefiltert – schneller Überblick)
+// ---------------------------------------------------------------
+[$deptFilterT, $deptParamsT] = dept_filter_sql($myDeptIds, 'department_id');
+
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE 1=1{$deptFilterT}");
+$stmt->execute($deptParamsT);
+$totalTickets = (int) $stmt->fetchColumn();
+
+$monthStart = date('Y-m-01 00:00:00');
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolved_at >= ?{$deptFilterT}");
+$stmt->execute([$monthStart, ...$deptParamsT]);
+$resolvedThisMonth = (int) $stmt->fetchColumn();
+
+$respTimes = [];
+$stmt = $pdo->prepare("SELECT created_at, first_responded_at FROM tickets WHERE first_responded_at IS NOT NULL{$deptFilterT}");
+$stmt->execute($deptParamsT);
+foreach ($stmt->fetchAll() as $r) {
+ $respTimes[] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600;
+}
+$avgResponse = $respTimes ? array_sum($respTimes) / count($respTimes) : null;
+
+$resolTimes = [];
+$stmt = $pdo->prepare("SELECT created_at, resolved_at FROM tickets WHERE resolved_at IS NOT NULL{$deptFilterT}");
+$stmt->execute($deptParamsT);
+foreach ($stmt->fetchAll() as $r) {
+ $resolTimes[] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600;
+}
+$avgResolution = $resolTimes ? array_sum($resolTimes) / count($resolTimes) : null;
+
+// ---------------------------------------------------------------
+// Mehrlinien-Aktivitätsdiagramm für den gewählten Zeitraum
+// ---------------------------------------------------------------
+function bucket_key_for(string $datetime, bool $weekly): string
+{
+ $d = new DateTime($datetime);
+ return $weekly ? 'KW' . $d->format('W') : $d->format('d.m');
+}
+
+$buckets = [];
+$cursor = clone $from;
+while ($cursor <= $to) {
+ $key = $useWeekly ? 'KW' . $cursor->format('W') : $cursor->format('d.m');
+ if (!isset($buckets[$key])) {
+ $buckets[$key] = ['erstellt' => 0, 'zugewiesen' => 0, 'geschlossen' => 0, 'geloescht' => 0, 'ueberfaellig' => 0];
+ }
+ $cursor->modify('+1 day');
+}
+
+$stmt = $pdo->prepare("SELECT created_at FROM tickets WHERE created_at BETWEEN ? AND ?{$deptFilterT}");
+$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
+foreach ($stmt->fetchAll() as $r) {
+ $k = bucket_key_for($r['created_at'], $useWeekly);
+ if (isset($buckets[$k])) $buckets[$k]['erstellt']++;
+}
+
+$stmt = $pdo->prepare(
+ "SELECT al.created_at FROM activity_log al JOIN tickets t ON t.id = al.ticket_id
+ WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT}"
+);
+$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
+foreach ($stmt->fetchAll() as $r) {
+ $k = bucket_key_for($r['created_at'], $useWeekly);
+ if (isset($buckets[$k])) $buckets[$k]['zugewiesen']++;
+}
+
+$stmt = $pdo->prepare("SELECT closed_at FROM tickets WHERE closed_at BETWEEN ? AND ?{$deptFilterT}");
+$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
+foreach ($stmt->fetchAll() as $r) {
+ $k = bucket_key_for($r['closed_at'], $useWeekly);
+ if (isset($buckets[$k])) $buckets[$k]['geschlossen']++;
+}
+
+[$deptFilterDel, $deptParamsDel] = dept_filter_sql($myDeptIds, 'department_id');
+$stmt = $pdo->prepare("SELECT deleted_at FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterDel}");
+$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsDel));
+foreach ($stmt->fetchAll() as $r) {
+ $k = bucket_key_for($r['deleted_at'], $useWeekly);
+ if (isset($buckets[$k])) $buckets[$k]['geloescht']++;
+}
+
+$stmt = $pdo->prepare(
+ "SELECT al.created_at FROM activity_log al JOIN tickets t ON t.id = al.ticket_id
+ WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT}"
+);
+$stmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
+foreach ($stmt->fetchAll() as $r) {
+ $k = bucket_key_for($r['created_at'], $useWeekly);
+ if (isset($buckets[$k])) $buckets[$k]['ueberfaellig']++;
+}
+
+$chartSeries = [
+ 'erstellt' => ['label' => 'Erstellt', 'color' => '#16a34a'],
+ 'zugewiesen' => ['label' => 'Zugewiesen', 'color' => '#2563eb'],
+ 'geschlossen' => ['label' => 'Geschlossen', 'color' => '#0f6f6a'],
+ 'geloescht' => ['label' => 'Gelöscht', 'color' => '#a855f7'],
+ 'ueberfaellig' => ['label' => 'Überfällig', 'color' => '#dc2626'],
+];
+
+// ---------------------------------------------------------------
+// Nach Status / Priorität (Gesamtüberblick, unabhängig vom Zeitraum)
+// ---------------------------------------------------------------
+$byStatus = [];
+foreach (['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'] as $s) {
+ [$label] = status_label($s);
+ $byStatus[$label] = 0;
+}
+$stmt = $pdo->prepare("SELECT status, COUNT(*) c FROM tickets WHERE 1=1{$deptFilterT} GROUP BY status");
+$stmt->execute($deptParamsT);
+foreach ($stmt->fetchAll() as $r) {
+ [$label] = status_label($r['status']);
+ $byStatus[$label] = (int) $r['c'];
+}
+
+$byPrio = [];
+$stmt = $pdo->prepare(
+ "SELECT p.name, p.sort_order, COUNT(t.id) c FROM priorities p
+ LEFT JOIN tickets t ON t.priority_id = p.id{$deptFilterT}
+ GROUP BY p.id, p.name, p.sort_order ORDER BY p.sort_order"
+);
+$stmt->execute($deptParamsT);
+foreach ($stmt->fetchAll() as $r) {
+ $byPrio[$r['name']] = (int) $r['c'];
+}
+
+// ---------------------------------------------------------------
+// SLA-Einhaltung im Zeitraum
+// ---------------------------------------------------------------
+$slaStmt = $pdo->prepare(
+ "SELECT COUNT(*) AS total,
+ SUM(CASE WHEN resolved_at <= resolution_due_at THEN 1 ELSE 0 END) AS on_time
+ FROM tickets
+ WHERE status IN ('resolved','closed')
+ AND resolved_at IS NOT NULL AND resolution_due_at IS NOT NULL
+ AND resolved_at BETWEEN ? AND ?{$deptFilterT}"
+);
+$slaStmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
+$slaRow = $slaStmt->fetch();
+$slaTotal = (int)($slaRow['total'] ?? 0);
+$slaOk = (int)($slaRow['on_time'] ?? 0);
+$slaRate = $slaTotal > 0 ? round($slaOk / $slaTotal * 100) : null;
+
+// Mitarbeiter-Performance im Zeitraum
+$agentPerf = [];
+if ($staff['role'] === 'admin') {
+ $avgResponseHours = report_avg_response_hours_expression(
+ $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)
+ );
+ $apStmt = $pdo->prepare(
+ "SELECT s.name,
+ COUNT(*) AS total,
+ SUM(CASE WHEN t.status IN ('resolved','closed') THEN 1 ELSE 0 END) AS resolved,
+ AVG(CASE WHEN t.first_responded_at IS NOT NULL
+ THEN {$avgResponseHours} END) AS avg_resp
+ FROM tickets t
+ JOIN staff s ON s.id = t.assigned_staff_id
+ WHERE t.created_at BETWEEN ? AND ?{$deptFilterT}
+ GROUP BY t.assigned_staff_id, s.name
+ ORDER BY resolved DESC LIMIT 10"
+ );
+ $apStmt->execute(array_merge([$rangeFrom, $rangeTo], $deptParamsT));
+ $agentPerf = $apStmt->fetchAll();
+}
+
+// Chart.js-Daten für Zeitreihe
+$chartLabels = array_keys($buckets);
+$chartCreated = array_column(array_values($buckets), 'erstellt');
+$chartClosed = array_column(array_values($buckets), 'geschlossen');
+
+// ---------------------------------------------------------------
+// Statistik-Tabellen je Abteilung / Thema / Agent
+// ---------------------------------------------------------------
+$deptRows = build_report_table($pdo, 'department_id', $rangeFrom, $rangeTo, $myDeptIds);
+$topicRows = build_report_table($pdo, 'help_topic_id', $rangeFrom, $rangeTo, $myDeptIds);
+
+$departments = $pdo->query('SELECT id, name FROM departments')->fetchAll(PDO::FETCH_KEY_PAIR);
+$agentNames = $pdo->query('SELECT id, name FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR);
+$helpTopics = $pdo->query('SELECT id, name FROM help_topics')->fetchAll(PDO::FETCH_KEY_PAIR);
+
+function render_report_table(array $rows, array $labelMap, string $noneLabel): string
+{
+ if (!$rows) return 'Keine Daten im gewählten Zeitraum.
';
+ ob_start();
+ ?>
+
+
+ Bezeichnung Geöffnet Zugewiesen Überfällig Geschlossen
+ Wieder geöffnet Gelöscht Servicezeit (Std) Reaktionszeit (Std)
+
+
+ $r): ?>
+
+ = $key === 'none' ? e($noneLabel) : e($labelMap[(int) $key] ?? "#{$key}") ?>
+ = (int)$r['geoeffnet'] ?>
+ = (int)$r['zugewiesen'] ?>
+ = (int)$r['ueberfaellig'] ?>
+ = (int)$r['geschlossen'] ?>
+ = (int)$r['wieder_geoeffnet'] ?>
+ = (int)$r['geloescht'] ?>
+ = $r['servicezeit'] !== null ? number_format((float)$r['servicezeit'], 1) : '–' ?>
+ = $r['reaktionszeit'] !== null ? number_format((float)$r['reaktionszeit'], 1) : '–' ?>
+
+
+
+
+ prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed'){$deptFilterT}");
+$stmt->execute($deptParamsT);
+$dashOpenTickets = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed'){$deptFilterT}");
+$stmt->execute(array_merge([$staff['id']], $deptParamsT));
+$dashMine = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed'){$deptFilterT}");
+$stmt->execute($deptParamsT);
+$dashUnassigned = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?{$deptFilterT}");
+$stmt->execute(array_merge([$now], $deptParamsT));
+$dashOverdue = (int) $stmt->fetchColumn();
+
+$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'{$deptFilterT}");
+$stmt->execute($deptParamsT);
+$dashUnanswered = (int) $stmt->fetchColumn();
+
+$mentionStmt = $pdo->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
+$mentionStmt->execute([$staff['id']]);
+$unreadMentions = (int) $mentionStmt->fetchColumn();
+
+$tsColors = ['available'=>'#22c55e','busy'=>'#f59e0b','break'=>'#6366f1','offline'=>'#94a3b8'];
+$tsLabels = ['available'=>'Verfügbar','busy'=>'Beschäftigt','break'=>'Pause','offline'=>'Offline'];
+$teamStatus = [];
+$myAvailStatus = 'available';
+try {
+ $tsStmt = $pdo->query(
+ "SELECT s.id, s.name, s.is_on_vacation, sa.status AS avail_status, sa.note AS avail_note
+ FROM staff s
+ LEFT JOIN staff_availability sa ON sa.staff_id = s.id
+ WHERE s.is_active = 1 ORDER BY s.name"
+ );
+ foreach ($tsStmt->fetchAll() as $row) {
+ if ($row['id'] === $staff['id']) {
+ $myAvailStatus = $row['avail_status'] ?? 'available';
+ } else {
+ $teamStatus[] = $row;
+ }
+ }
+} catch (Throwable $e) {}
+
+$pageTitle = 'Übersicht';
+$activePage = 'reports';
+require __DIR__ . '/_header.php';
+?>
+
+
+
Willkommen, = e($staff['name']) ?>
+
+
+
+
+
+true, CURLOPT_TIMEOUT=>4, CURLOPT_SSL_VERIFYPEER=>true, CURLOPT_PROTOCOLS=>CURLPROTO_HTTPS, CURLOPT_HTTPHEADER=>update_http_headers()]);
+ $__resp = curl_exec($__ch); curl_close($__ch);
+ $__rel = @json_decode($__resp, true);
+ if (!empty($__rel[0]['tag_name'])) $__latestVersion = $__rel[0]['tag_name'];
+ }
+ $__hasUpdate = $__latestVersion && version_compare($__latestVersion, DESKHUB_VERSION, '>');
+?>
+
+
+
+
🆕
+
+
Update verfügbar: Version = e($__latestVersion) ?>
+
Aktuell installiert: v= DESKHUB_VERSION ?>
+
+
+
+ Zum Update-Bereich →
+
+
+
+
+
+
+
= $dashOpenTickets ?>
Offene Tickets
+
= $dashMine ?>
Mir zugewiesen
+
= $dashUnassigned ?>
Nicht zugewiesen
+
= $dashOverdue ?>
Überfällig
+
= $dashUnanswered ?>
Unbeantwortet
+ 0): ?>
+
= $unreadMentions ?>
🔔 Neue Erwähnungen
+
+
+
+
+= csrf_field() ?>
+
+
+
+
+
+ Mein Status:
+ $sc):
+ $active = $myAvailStatus === $sk;
+ ?>
+
+
+ = $tsLabels[$sk] ?>
+
+
+
+
+
+
+
+
+
+
+
Team:
+
+
+
+ = e($tm['name']) ?>
+ = e($tsl) ?>
+
+
+
+
+
+
+
+
+
+
+Berichte
+
+
+
= $totalTickets ?>
Tickets gesamt
+
= $resolvedThisMonth ?>
Gelöst diesen Monat
+
= $avgResponse !== null ? round($avgResponse, 1) . ' Std' : '–' ?>
Ø Erstreaktion
+
= $avgResolution !== null ? round($avgResolution, 1) . ' Std' : '–' ?>
Ø Lösungszeit
+
+
+
+
+ Berichtszeitraum
+
+ >Letzte 7 Tage
+ >Letzte 30 Tage
+ >Dieser Monat
+ >Dieses Jahr
+ >Benutzerdefiniert
+
+
+
+ Aktualisieren
+
+
+
+
Ticketaktivität (= e($from->format('d.m.Y')) ?> – = e($to->format('d.m.Y')) ?>)
+
+
+
+
+ = e($info['label']) ?>
+
+
+
+
+
+
+ 0): ?>
+
+
+
+
SLA-Einhaltung im Zeitraum
+
= $slaRate ?>%
+
= $slaOk ?> von = $slaTotal ?> Tickets innerhalb der Frist gelöst
+
+
+
+ Im Zeitlimit Überfällig
+
+
+
+ = $slaOk ?> ✓
+ = $slaTotal - $slaOk ?> ✗
+
+
+
+
+
+
+
+
+
+
Nach Status
+
+
+
+
Nach Priorität
+
+
+
+
+
+
+
+
Statistiken (= e($from->format('d.m.Y')) ?> – = e($to->format('d.m.Y')) ?>)
+
+ Abteilung
+ Themen
+
+ Mitarbeiter
+
+
+ $preset, 'from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')];
+ ?>
+
+
+ = render_report_table($topicRows, $helpTopics, 'Kein Hilfethema') ?>
+
Exportieren (CSV)
+
+
+
+
+
+ Mitarbeiter
+ Tickets
+ Gelöst
+ Lösungsrate
+ Ø Erstantwort
+ Leistung
+
+
+ 0 ? round($ap['resolved'] / $ap['total'] * 100) : 0;
+ $barPct = $maxResolved > 0 ? round($ap['resolved'] / $maxResolved * 100) : 0;
+ $avgR = $ap['avg_resp'] !== null ? round((float)$ap['avg_resp'], 1) . ' Std' : '–';
+ ?>
+
+ = e($ap['name']) ?>
+ = (int)$ap['total'] ?>
+ = (int)$ap['resolved'] ?>
+
+ = $rate ?>%
+
+ = $avgR ?>
+
+
+
+
+
+
+
+
Exportieren (CSV)
+
+
+
+
+query('SELECT name, color FROM priorities ORDER BY sort_order')->fetchAll() as $pc) {
+ $prioColorMap[$pc['name']] = $pc['color'];
+}
+$jsChartLabels = json_encode($chartLabels);
+$jsChartCreated = json_encode($chartCreated);
+$jsChartClosed = json_encode($chartClosed);
+$jsStatusLabels = json_encode(array_keys($byStatus));
+$jsStatusData = json_encode(array_values($byStatus));
+$jsPrioLabels = json_encode(array_keys($byPrio));
+$jsPrioData = json_encode(array_values($byPrio));
+$jsPrioColors = json_encode($prioColorMap);
+?>
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/reset_password.php b/DeskHub-Webseite/staff/reset_password.php
new file mode 100644
index 00000000..82533e41
--- /dev/null
+++ b/DeskHub-Webseite/staff/reset_password.php
@@ -0,0 +1,72 @@
+prepare('UPDATE staff SET password_hash = ? WHERE email = ?')
+ ->execute([password_hash($pass1, PASSWORD_DEFAULT), $email]);
+ consume_password_reset('staff', $token);
+ $staffRow = $pdo->prepare('SELECT id FROM staff WHERE email = ?');
+ $staffRow->execute([$email]);
+ audit_log((int) $staffRow->fetchColumn(), 'Passwort per Reset-Link zurückgesetzt');
+ $done = true;
+ }
+}
+
+$pageTitle = 'Passwort zurücksetzen';
+?>
+
+
+
+
+
+Passwort zurücksetzen – Mitarbeiterbereich
+ ">
+
+
+
+
+
+
Neues Passwort festlegen
+
+
= e($err) ?>
+
+
+
Ihr Passwort wurde geändert. Sie können sich jetzt anmelden.
+
Zur Anmeldung →
+
+
+ = csrf_field() ?>
+
+ Neues Passwort
+
+ Neues Passwort wiederholen
+
+
+ Passwort speichern
+
+
+
+
Neuen Link anfordern →
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/roles.php b/DeskHub-Webseite/staff/roles.php
new file mode 100644
index 00000000..dd47320a
--- /dev/null
+++ b/DeskHub-Webseite/staff/roles.php
@@ -0,0 +1,198 @@
+ 'Tickets beantworten',
+ 'close_ticket' => 'Tickets schließen/lösen',
+ 'delete_ticket' => 'Tickets löschen',
+ 'transfer_ticket' => 'Tickets übertragen (Abteilung ändern)',
+ 'post_note' => 'Interne Notizen schreiben',
+ 'assign_ticket' => 'Tickets zuweisen (Mitarbeiter/Team)',
+ 'manage_canned' => 'Textbausteine verwalten',
+ 'view_reports' => 'Berichte einsehen',
+ 'manage_kb' => 'Wissensdatenbank verwalten',
+ 'create_ticket' => 'Tickets im Namen von Kunden erstellen',
+];
+// Feinkörnige Adminportal-Rechte ("Co-Admin") – siehe admin_section_permissions() in functions.php.
+// manage_kb steckt bewusst nur oben bei den Ticket-Rechten, nicht doppelt hier.
+$adminPermLabels = admin_section_permissions();
+unset($adminPermLabels['manage_kb']);
+$permLabels = array_merge($ticketPermLabels, $adminPermLabels);
+
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $id = (int) ($_POST['id'] ?? 0);
+ $name = trim($_POST['name'] ?? '');
+ $isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0;
+ $notes = trim($_POST['notes'] ?? '');
+ if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
+ $dup->execute([$name, $id]);
+ if ($dup->fetchColumn() > 0) $errors[] = 'Eine Rolle mit diesem Namen existiert bereits.';
+ if (!$errors) {
+ $perms = [];
+ foreach (array_keys($permLabels) as $p) {
+ $perms[$p] = !empty($_POST['perm_' . $p]);
+ }
+ $json = json_encode($perms);
+ if ($action === 'create') {
+ $pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?)')
+ ->execute([$name, $json, $isActive, $notes ?: null, db_now(), db_now()]);
+ audit_log($staff['id'], "Rolle erstellt: {$name}");
+ flash('success', 'Rolle erstellt.');
+ } else {
+ $pdo->prepare('UPDATE roles SET name=?, permissions=?, is_active=?, notes=?, updated_at=? WHERE id=?')
+ ->execute([$name, $json, $isActive, $notes ?: null, db_now(), $id]);
+ audit_log($staff['id'], "Rolle aktualisiert: {$name}");
+ flash('success', 'Änderungen gespeichert.');
+ }
+ redirect('/staff/roles.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ // Mitarbeiter und Abteilungszugriffe auf die Hauptrolle zurücksetzen
+ $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}");
+ flash('success', 'Rolle gelöscht.');
+ redirect('/staff/roles.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$roles = $pdo->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();
+
+$editRole = null;
+$editPerms = array_fill_keys(array_keys($permLabels), false);
+if ($editId) {
+ $es = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); $es->execute([$editId]);
+ $editRole = $es->fetch() ?: null;
+ if ($editRole) $editPerms = array_merge($editPerms, json_decode($editRole['permissions'] ?? '{}', true) ?? []);
+}
+if ($_POST && $errors) foreach (array_keys($permLabels) as $p) $editPerms[$p] = !empty($_POST['perm_' . $p]);
+
+$pageTitle = 'Rollen'; $activePage = 'roles';
+require __DIR__ . '/_header.php';
+?>
+
+= e($err) ?>
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editRole[$col] ?? $default);
+ $curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editRole['is_active'] ?? 1);
+?>
+
+ = $editRole ? 'Rolle aktualisieren' : 'Neue Rolle' ?>
+ — = e($editRole['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+ 'Rolle', 'perms' => 'Berechtigungen']); ?>
+
+
+
Rolleninformationen
+
Name *
+
+
+
Eine deaktivierte Rolle wird ignoriert; betroffene Mitarbeiter haben dann die Standard-Mitarbeiterrechte.
+
Administratornotizen – Interne Notizen, sichtbar für alle Administratoren
+
= e($v('notes', 'notes')) ?>
+
+
+
+
Administratoren haben immer alle Rechte. Die Rechte einer Rolle gelten für Mitarbeiter mit dieser Rolle – je Abteilung kann ein Mitarbeiter eine andere Rolle haben (Mitarbeiter → Zugang).
+
Tickets
+
+ $label): ?>
+
+ > = e($label) ?>
+
+
+
+
Adminportal-Bereiche („Co-Admin“)
+
+ $label): ?>
+
+ > = e($label) ?>
+
+
+
+
+
+
+
= $editRole ? 'Änderungen speichern' : 'Rolle erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+Benutzerdefinierte Berechtigungssets für Mitarbeiter. Ohne Rolle gelten die Standard-Mitarbeiterrechte.
+
+
+
+
+ Standard-Mitarbeiterrechte (ohne eigene Rolle)
+
+ $label): ?>
+
= ($defaults[$key] ?? false) ? '✓' : '✕' ?> = e($label) ?>
+
+
+
+
+
+
+
+ Rollenname Status Zugewiesen Erstellt Zuletzt aktualisiert
+
+
+
+ = e($r['name']) ?>
+ = ($r['is_active'] ?? 1) ? 'Aktiv' : 'Deaktiviert' ?>
+ = (int) $r['staff_count'] ?>
+ = e(ui_date($r['created_at'] ?? null)) ?>
+ = e(ui_date($r['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+ Noch keine benutzerdefinierten Rollen angelegt.
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/security_check.php b/DeskHub-Webseite/staff/security_check.php
new file mode 100644
index 00000000..29bc9096
--- /dev/null
+++ b/DeskHub-Webseite/staff/security_check.php
@@ -0,0 +1,40 @@
+ 0, 'warn' => 0, 'info' => 0, 'ok' => 0];
+foreach ($results as $r) $counts[$r['level']]++;
+
+$pageTitle = 'Sicherheitsprüfung';
+$activePage = 'security_check';
+require __DIR__ . '/_header.php';
+
+$style = [
+ 'error' => ['#dc2626', '❌', 'Handeln'],
+ 'warn' => ['#d97706', '⚠️', 'Prüfen'],
+ 'info' => ['#2563eb', 'ℹ️', 'Hinweis'],
+ 'ok' => ['#15803d', '✅', 'In Ordnung'],
+];
+?>
+
+
+
Sicherheitsprüfung
+
Prüft die Einrichtung dieser Installation und ruft interne Adressen selbst ab, um zu sehen, ob sie von außen erreichbar wären.
+
+
🔄 Erneut prüfen
+
+
+
+ = $counts['error'] ?> Handlungsbedarf · = $counts['warn'] ?> Prüfen · = $counts['info'] ?> Hinweise · = $counts['ok'] ?> in Ordnung
+
+
+
+
+
= $icon ?> = e($r['title']) ?> · = e($label) ?>
+
= e($r['detail']) ?>
+
+
+
+
diff --git a/DeskHub-Webseite/staff/session_ping.php b/DeskHub-Webseite/staff/session_ping.php
new file mode 100644
index 00000000..03e43a3b
--- /dev/null
+++ b/DeskHub-Webseite/staff/session_ping.php
@@ -0,0 +1,41 @@
+ 'Method not allowed']);
+ exit;
+}
+
+// Muss eingeloggt sein
+if (empty($_SESSION['staff_id'])) {
+ http_response_code(401);
+ echo json_encode(['error' => 'Nicht eingeloggt', 'remaining' => 0]);
+ exit;
+}
+
+$timeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
+$last = $_SESSION['staff_last_activity'] ?? 0;
+$elapsed = time() - $last;
+
+// Abgelaufen? Dann nicht mehr verlängern
+if ($last && $elapsed > ($timeout * 60)) {
+ http_response_code(401);
+ echo json_encode(['error' => 'Session abgelaufen', 'remaining' => 0]);
+ exit;
+}
+
+// Aktivität erneuern
+$_SESSION['staff_last_activity'] = time();
+$remaining = $timeout * 60;
+
+echo json_encode(['ok' => true, 'remaining' => $remaining]);
diff --git a/DeskHub-Webseite/staff/settings.php b/DeskHub-Webseite/staff/settings.php
new file mode 100644
index 00000000..ea89348d
--- /dev/null
+++ b/DeskHub-Webseite/staff/settings.php
@@ -0,0 +1,1068 @@
+ ['label' => 'E-Mail-Abruf', 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'],
+ 'check_sla' => ['label' => 'SLA-Prüfung', 'file' => 'check_sla.php', 'fn' => 'run_check_sla'],
+ 'check_escalations' => ['label' => 'Eskalationsregeln', 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'],
+ 'process_recurring' => ['label' => 'Wiederkehrende Tickets', 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
+ 'process_backups' => ['label' => 'Automatische Backups', 'file' => 'process_backups.php', 'fn' => 'run_process_backups'],
+ 'anonymize_data' => ['label' => 'Datenanonymisierung', 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
+ 'auto_close_tickets'=> ['label' => 'Automatisches Schließen', 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
+];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'save_system') {
+ $status = ($_POST['helpdesk_status'] ?? 'online') === 'offline' ? 'offline' : 'online';
+ $offlineMsg = trim($_POST['offline_message'] ?? '');
+ $defaultDept = (int)($_POST['default_department_id'] ?? 0);
+ $kbEnabled = !empty($_POST['kb_enabled']) ? '1' : '0';
+ if (!in_array($defaultDept, array_column($departments, 'id'))) {
+ $errors[] = 'Bitte eine gültige Standardabteilung wählen.';
+ } else {
+ set_setting('helpdesk_status', $status);
+ set_setting('offline_message', $offlineMsg ?: 'Das Supportcenter ist derzeit nicht erreichbar.');
+ set_setting('default_department_id', (string)$defaultDept);
+ set_setting('kb_enabled', $kbEnabled);
+ set_setting('ticket_ratings_enabled', !empty($_POST['ticket_ratings_enabled']) ? '1' : '0');
+ set_setting('auto_time_tracking_enabled', !empty($_POST['auto_time_tracking_enabled']) ? '1' : '0');
+ set_setting('live_chat_enabled', !empty($_POST['live_chat_enabled']) ? '1' : '0');
+ $liveChatDept = $_POST['live_chat_department_id'] ?? '';
+ set_setting('live_chat_department_id', ($liveChatDept !== '' && in_array((int)$liveChatDept, array_column($departments, 'id'))) ? (string)(int)$liveChatDept : '');
+ set_setting('live_chat_send_transcript', !empty($_POST['live_chat_send_transcript']) ? '1' : '0');
+ set_setting('auto_close_enabled', !empty($_POST['auto_close_enabled']) ? '1' : '0');
+ $autoCloseDays = max(1, min(90, (int) ($_POST['auto_close_days'] ?? 7)));
+ set_setting('auto_close_days', (string) $autoCloseDays);
+ $timeout = max(5, min(480, (int)($_POST['staff_session_timeout'] ?? 30)));
+ set_setting('staff_session_timeout', (string)$timeout);
+ audit_log($staff['id'], "Systemeinstellungen geändert");
+ flash('success', 'Systemeinstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=allgemein');
+ }
+ }
+
+ if ($action === 'save_privacy') {
+ $enabled = !empty($_POST['retention_enabled']) ? '1' : '0';
+ $months = max(1, min(120, (int)($_POST['retention_months'] ?? 24)));
+ $ipAnon = !empty($_POST['ip_anonymize']) ? '1' : '0';
+ set_setting('retention_enabled', $enabled);
+ set_setting('retention_months', (string)$months);
+ set_setting('ip_anonymize', $ipAnon);
+ audit_log($staff['id'], 'Datenschutzeinstellungen geändert');
+ flash('success', 'Datenschutzeinstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=datenschutz');
+ }
+
+ if ($action === 'run_anonymize_now') {
+ define('DESKHUB_CRON_RUNNER', true);
+ require_once __DIR__ . '/../cron/anonymize_data.php';
+ $result = run_anonymization(get_db(), true);
+ audit_log($staff['id'], "Datenanonymisierung manuell ausgeführt: {$result['count']} Ticket(s)");
+ flash('success', "{$result['count']} Ticket(s) anonymisiert.");
+ redirect('/staff/settings.php?tab=datenschutz');
+ }
+
+ // Manueller Start eines Cronjobs aus dem Backend ("Jetzt ausführen"-Button, Tab Cron-Jobs).
+ // Führt exakt die gleiche Logik aus wie der echte Cronjob, nur direkt per Klick statt per crontab.
+ if ($action === 'run_cron_now') {
+ $job = $_POST['job'] ?? '';
+ if (!isset($cronJobs[$job])) {
+ flash('error', 'Unbekannter Cronjob.');
+ redirect('/staff/settings.php?tab=cron');
+ }
+ $meta = $cronJobs[$job];
+ define('DESKHUB_CRON_RUNNER', true);
+ require_once __DIR__ . '/../cron/' . $meta['file'];
+ $fn = $meta['fn'];
+
+ try {
+ if ($job === 'anonymize_data') {
+ $result = $fn(get_db(), true);
+ $summary = "{$result['count']} Ticket(s) anonymisiert.";
+ } elseif ($job === 'process_backups') {
+ // Manueller Klick erzwingt ein Backup unabhängig vom Zeitplan.
+ $result = $fn(get_db(), true, true);
+ $summary = $result['ok'] ? ($result['ran'] ? 'Backup erstellt.' : 'Kein Backup nötig.') : 'Backup fehlgeschlagen.';
+ } else {
+ $result = $fn(get_db(), true);
+ $summary = end($result['log']) ?: 'Ausgeführt.';
+ }
+ audit_log($staff['id'], "Cronjob manuell ausgeführt: {$meta['label']} – {$summary}");
+ flash('success', "{$meta['label']}: {$summary}");
+ } catch (Throwable $e) {
+ audit_log($staff['id'], "Cronjob manuell fehlgeschlagen: {$meta['label']} – " . $e->getMessage());
+ flash('error', "{$meta['label']} fehlgeschlagen: " . $e->getMessage());
+ }
+ redirect('/staff/settings.php?tab=cron');
+ }
+
+ // Ein-/Ausschalten des eingebauten Auto-Cron (siehe includes/pseudo_cron.php).
+ if ($action === 'save_pseudo_cron') {
+ $enabled = !empty($_POST['pseudo_cron_enabled']) ? '1' : '0';
+ set_setting('pseudo_cron_enabled', $enabled);
+ audit_log($staff['id'], $enabled === '1' ? 'Automatischer Hintergrund-Cron aktiviert' : 'Automatischer Hintergrund-Cron deaktiviert');
+ flash('success', 'Gespeichert.');
+ redirect('/staff/settings.php?tab=cron');
+ }
+
+ // Echte System-Crontab installieren/entfernen (nur auf Servern mit Shell-Zugriff möglich,
+ // siehe includes/crontab_installer.php – schreibt ausschließlich feste, fest hinterlegte Zeilen).
+ if ($action === 'install_crontab') {
+ $result = crontab_install();
+ if ($result['ok']) {
+ audit_log($staff['id'], 'Server-Crontab installiert/aktualisiert (6 DeskHub-Cronjobs)');
+ flash('success', 'Crontab installiert. Die 6 Cronjobs laufen jetzt direkt über den Server-Scheduler.');
+ } else {
+ audit_log($staff['id'], 'Server-Crontab-Installation fehlgeschlagen: ' . $result['error']);
+ flash('error', 'Installation fehlgeschlagen: ' . $result['error']);
+ }
+ redirect('/staff/settings.php?tab=cron');
+ }
+
+ if ($action === 'remove_crontab') {
+ $result = crontab_uninstall();
+ if ($result['ok']) {
+ audit_log($staff['id'], 'Server-Crontab-Eintrag von DeskHub entfernt');
+ flash('success', 'Crontab-Eintrag entfernt.');
+ } else {
+ audit_log($staff['id'], 'Entfernen der Server-Crontab fehlgeschlagen: ' . $result['error']);
+ flash('error', 'Entfernen fehlgeschlagen: ' . $result['error']);
+ }
+ redirect('/staff/settings.php?tab=cron');
+ }
+
+ if ($action === 'save_name') {
+ $name = trim($_POST['display_name'] ?? '');
+ if (!$name) { $errors[] = 'Bitte einen Anzeigenamen angeben.'; }
+ else {
+ set_setting('display_name', $name);
+ $siteUrl = rtrim(trim($_POST['site_url'] ?? ''), '/');
+ if ($siteUrl) set_setting('site_url', $siteUrl);
+ $tz = trim($_POST['timezone'] ?? '');
+ if ($tz && in_array($tz, timezone_identifiers_list(), true)) set_setting('timezone', $tz);
+ set_setting('mail_enabled', !empty($_POST['mail_enabled']) ? '1' : '0');
+ audit_log($staff['id'], "Anzeigename / Basis-Einstellungen geändert: $name");
+ flash('success', 'Einstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=allgemein');
+ }
+ }
+
+ if ($action === 'upload_logo') {
+ $file = $_FILES['logo'] ?? [];
+ $allowed = ['png','jpg','jpeg','gif','svg'];
+ $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
+ if (empty($file['name'])) { $errors[] = 'Bitte eine Bilddatei auswählen.'; }
+ elseif ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = 'Upload fehlgeschlagen.'; }
+ elseif ($file['size'] > 2*1024*1024) { $errors[] = 'Max. 2 MB.'; }
+ elseif (!in_array($ext, $allowed, true)) { $errors[] = 'Erlaubt: PNG, JPG, GIF, SVG.'; }
+ else {
+ foreach ($allowed as $e2) @unlink($branding.'/logo.'.$e2);
+ $dest = $branding.'/logo.'.$ext;
+ if (!move_uploaded_file($file['tmp_name'], $dest)) { $errors[] = 'Speichern fehlgeschlagen.'; }
+ else { set_setting('logo_path', '/assets/branding/logo.'.$ext.'?v='.time()); audit_log($staff['id'],'Logo aktualisiert'); flash('success','Logo aktualisiert.'); redirect('/staff/settings.php?tab=design'); }
+ }
+ }
+ if ($action === 'remove_logo') {
+ foreach (['png','jpg','jpeg','gif','svg'] as $e2) @unlink($branding.'/logo.'.$e2);
+ set_setting('logo_path', null); audit_log($staff['id'],'Logo entfernt'); flash('success','Logo entfernt.'); redirect('/staff/settings.php?tab=design');
+ }
+ if ($action === 'upload_banner') {
+ $file = $_FILES['banner'] ?? [];
+ $allowed = ['png','jpg','jpeg','gif'];
+ $ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
+ if (empty($file['name'])) { $errors[] = 'Bitte eine Bilddatei auswählen.'; }
+ elseif ($file['error'] !== UPLOAD_ERR_OK) { $errors[] = 'Upload fehlgeschlagen.'; }
+ elseif ($file['size'] > 5*1024*1024) { $errors[] = 'Max. 5 MB.'; }
+ elseif (!in_array($ext, $allowed, true)) { $errors[] = 'Erlaubt: PNG, JPG, GIF.'; }
+ else {
+ foreach ($allowed as $e2) @unlink($branding.'/banner.'.$e2);
+ $dest = $branding.'/banner.'.$ext;
+ if (!move_uploaded_file($file['tmp_name'], $dest)) { $errors[] = 'Speichern fehlgeschlagen.'; }
+ else { set_setting('banner_path', '/assets/branding/banner.'.$ext.'?v='.time()); audit_log($staff['id'],'Titelbild aktualisiert'); flash('success','Titelbild aktualisiert.'); redirect('/staff/settings.php?tab=design'); }
+ }
+ }
+ if ($action === 'remove_banner') {
+ foreach (['png','jpg','jpeg','gif'] as $e2) @unlink($branding.'/banner.'.$e2);
+ set_setting('banner_path', null); audit_log($staff['id'],'Titelbild entfernt'); flash('success','Titelbild entfernt.'); redirect('/staff/settings.php?tab=design');
+ }
+ if ($action === 'save_homepage_content') {
+ $popularArticlesEnabled = !empty($_POST['homepage_popular_articles_enabled']) ? '1' : '0';
+ set_setting('homepage_popular_articles_enabled', $popularArticlesEnabled);
+ audit_log(
+ $staff['id'],
+ $popularArticlesEnabled === '1'
+ ? 'Beliebte Artikel auf der Startseite eingeblendet'
+ : 'Beliebte Artikel auf der Startseite ausgeblendet'
+ );
+ flash('success', 'Startseiten-Inhalte gespeichert.');
+ redirect('/staff/settings.php?tab=design');
+ }
+ if ($action === 'upload_custom_theme') {
+ try {
+ $theme = install_custom_theme_upload($_FILES['theme_zip'] ?? []);
+ $actionLabel = $theme['replaced'] ? 'aktualisiert' : 'hochgeladen';
+ audit_log($staff['id'], "Kundenportal-Theme {$actionLabel}: {$theme['name']}");
+ flash('success', "Theme „{$theme['name']}“ wurde {$actionLabel}.");
+ redirect('/staff/settings.php?tab=design');
+ } catch (Throwable $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+ if ($action === 'save_theme_catalog' && plugin_strict_mode()) {
+ $errors[] = 'Im geprüften Modus ist der Katalog fest vorgegeben.';
+ } elseif ($action === 'save_theme_catalog') {
+ $url = trim((string) ($_POST['theme_catalog_url'] ?? ''));
+ if ($url !== '' && ($urlError = plugin_url_check($url))) {
+ $errors[] = 'Katalog-Adresse: ' . $urlError;
+ } else {
+ set_setting('theme_catalog_url', $url);
+ flash('success', 'Katalog-Adresse gespeichert.');
+ redirect('/staff/settings.php?tab=design');
+ }
+ }
+ if ($action === 'theme_catalog_install') {
+ $res = theme_catalog_install((string) ($_POST['slug'] ?? ''));
+ if ($res['ok']) {
+ audit_log($staff['id'], "Kundenportal-Theme aus dem Katalog installiert: {$res['theme']['name']} (SHA-256 {$res['hash']}" . ($res['signed'] ? ', signiert' : ', nicht signiert') . ')');
+ flash('success', "Theme „{$res['theme']['name']}“ installiert (" . ($res['signed'] ? 'Signatur geprüft' : 'nicht signiert') . '). Es ist noch nicht aktiv.');
+ redirect('/staff/settings.php?tab=design');
+ }
+ $errors[] = $res['error'];
+ }
+ if ($action === 'activate_custom_theme') {
+ $themeId = (string) ($_POST['theme_id'] ?? '');
+ $theme = null;
+ foreach (list_custom_themes() as $candidate) {
+ if ($candidate['id'] === $themeId) {
+ $theme = $candidate;
+ break;
+ }
+ }
+ if ($theme === null) {
+ $errors[] = 'Das ausgewählte Theme wurde nicht gefunden.';
+ } else {
+ set_setting('active_custom_theme', $themeId);
+ audit_log($staff['id'], "Kundenportal-Theme aktiviert: {$theme['name']}");
+ flash('success', "Theme „{$theme['name']}“ ist jetzt aktiv.");
+ redirect('/staff/settings.php?tab=design');
+ }
+ }
+ if ($action === 'deactivate_custom_theme') {
+ set_setting('active_custom_theme', null);
+ audit_log($staff['id'], 'Benutzerdefiniertes Kundenportal-Theme deaktiviert');
+ flash('success', 'Das Standarddesign ist jetzt aktiv.');
+ redirect('/staff/settings.php?tab=design');
+ }
+ if ($action === 'delete_custom_theme') {
+ $themeId = (string) ($_POST['theme_id'] ?? '');
+ $theme = null;
+ foreach (list_custom_themes() as $candidate) {
+ if ($candidate['id'] === $themeId) {
+ $theme = $candidate;
+ break;
+ }
+ }
+ if ($theme === null || !delete_custom_theme($themeId)) {
+ $errors[] = 'Das Theme konnte nicht gelöscht werden.';
+ } else {
+ if (get_setting('active_custom_theme', '') === $themeId) {
+ set_setting('active_custom_theme', null);
+ }
+ audit_log($staff['id'], "Kundenportal-Theme gelöscht: {$theme['name']}");
+ flash('success', "Theme „{$theme['name']}“ wurde gelöscht.");
+ redirect('/staff/settings.php?tab=design');
+ }
+ }
+
+ if ($action === 'save_uploads') {
+ $dangerous = ['php','php3','php4','php5','phtml','phar','exe','bat','cmd','sh','pl','py','asp','aspx','jsp','cgi'];
+ $extList = array_filter(array_map('trim', explode(',', strtolower($_POST['upload_allowed_extensions'] ?? ''))));
+ $extList = array_diff($extList, $dangerous);
+ set_setting('upload_allowed_extensions', implode(', ', $extList));
+ set_setting('upload_max_size_mb', max(1, min(100, (int)($_POST['upload_max_size_mb'] ?? 10))));
+ audit_log($staff['id'], 'Upload-Einstellungen geändert');
+ flash('success', 'Upload-Einstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=uploads');
+ }
+
+ if ($action === 'save_footer_pages') {
+ foreach (['footer_privacy_label','footer_privacy_url','footer_privacy_html','footer_imprint_label','footer_imprint_url','footer_imprint_html'] as $k)
+ set_setting($k, trim($_POST[$k] ?? '') ?: null);
+ audit_log($staff['id'], 'Footer-Seiten geändert');
+ flash('success', 'Footer-Einstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=footer');
+ }
+
+ if ($action === 'save_notifications') {
+ $discordNew = trim($_POST['discord_webhook_url'] ?? '');
+ $tgTokenNew = trim($_POST['telegram_bot_token'] ?? '');
+ $tgChatId = trim($_POST['telegram_chat_id'] ?? '');
+ // Leer lassen = unverändert; Wert "-" = explizit löschen; sonst verschlüsseln
+ if ($discordNew !== '') {
+ set_setting('discord_webhook_url', $discordNew === '-' ? null : encrypt_secret($discordNew));
+ }
+ if ($tgTokenNew !== '') {
+ set_setting('telegram_bot_token', $tgTokenNew === '-' ? null : encrypt_secret($tgTokenNew));
+ }
+ if ($tgChatId !== '') {
+ set_setting('telegram_chat_id', $tgChatId === '-' ? null : $tgChatId);
+ }
+ audit_log($staff['id'], 'Benachrichtigungen geändert');
+ flash('success', 'Benachrichtigungseinstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=benachrichtigungen');
+ }
+ if ($action === 'test_notifications') {
+ $sent = [];
+ if (discord_notify('🔔 Testnachricht von DeskHub')) $sent[] = 'Discord';
+ if (telegram_notify('🔔 Testnachricht von DeskHub')) $sent[] = 'Telegram';
+ $sent ? flash('success', 'Gesendet an: '.implode(', ', $sent)) : flash('error', 'Kein Kanal konfiguriert oder Fehler.');
+ redirect('/staff/settings.php?tab=benachrichtigungen');
+ }
+
+ if ($action === 'save_smtp') {
+ set_setting('smtp_host', trim($_POST['smtp_host'] ?? '') ?: null);
+ set_setting('smtp_port', trim($_POST['smtp_port'] ?? '') ?: null);
+ set_setting('smtp_encryption', $_POST['smtp_encryption'] ?? 'tls');
+ set_setting('smtp_username', trim($_POST['smtp_username'] ?? '') ?: null);
+ if (trim($_POST['smtp_password'] ?? '') !== '') set_setting('smtp_password', encrypt_secret(trim($_POST['smtp_password'])));
+ set_setting('smtp_from_name', trim($_POST['smtp_from_name'] ?? '') ?: null);
+ set_setting('smtp_from_email', trim($_POST['smtp_from_email'] ?? '') ?: null);
+ audit_log($staff['id'], 'SMTP geändert');
+ flash('success', 'SMTP-Einstellungen gespeichert.');
+ redirect('/staff/settings.php?tab=email');
+ }
+ if ($action === 'test_smtp') {
+ $testBody = email_wrap(email_p('Diese Test-E-Mail bestätigt, dass Ihr SMTP-Versand korrekt eingerichtet ist. 🎉'));
+ $ok = send_mail($staff['email'], 'DeskHub – Test-E-Mail', $testBody);
+ $ok ? flash('success', 'Test-Mail an '.$staff['email'].' gesendet.') : flash('error', 'Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. '.smtp_last_error());
+ redirect('/staff/settings.php?tab=email');
+ }
+ // Einzelnes globales IMAP-Konto wurde durch die Tabelle imap_accounts abgelöst
+ // (mehrere Postfächer möglich, siehe staff/imap_accounts.php). Die alten
+ // Einzel-Settings (imap_host/imap_user/...) werden nur noch einmalig per
+ // Migration php_0011_imap_accounts als erstes Postfach übernommen.
+}
+
+// ── Lazy-Encryption: Klartext-Credentials beim ersten Admin-Besuch sichern ──
+// Direkt über PDO (nicht get_setting) um den statischen Cache zu umgehen.
+// Idempotent: AES-GCM-Blobs (base64, min. 28 Bytes) werden nicht erneut verschlüsselt.
+foreach (['discord_webhook_url', 'telegram_bot_token', 'smtp_password', 'imap_pass'] as $_lek) {
+ $stmt_le = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
+ $stmt_le->execute([$_lek]);
+ $raw_le = $stmt_le->fetchColumn();
+ if ($raw_le !== false && $raw_le !== '' && $raw_le !== null) {
+ $decoded_le = base64_decode($raw_le, true);
+ if ($decoded_le === false || strlen($decoded_le) < 28) {
+ // Klartext gefunden → jetzt verschlüsseln
+ $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ?')
+ ->execute([encrypt_secret($raw_le), $_lek]);
+ }
+ }
+}
+unset($_lek, $stmt_le, $raw_le, $decoded_le);
+
+// ── Werte laden ───────────────────────────────────────────────────────────────
+$currentLogo = get_setting('logo_path');
+$currentBanner = get_setting('banner_path');
+$homepagePopularArticlesEnabled = get_setting('homepage_popular_articles_enabled', '1') === '1';
+$currentName = get_setting('display_name', config()['site']['name']);
+$helpdeskStatus = get_setting('helpdesk_status', 'online');
+$offlineMessage = get_setting('offline_message', 'Das Supportcenter ist derzeit nicht erreichbar.');
+$defaultDeptId = get_setting('default_department_id', (string)($departments[0]['id'] ?? ''));
+$kbEnabled = get_setting('kb_enabled', '1') === '1';
+$discordUrl = get_setting('discord_webhook_url', '');
+$telegramToken = get_setting('telegram_bot_token', '');
+$telegramChatId = get_setting('telegram_chat_id', '');
+$smtpHost = get_setting('smtp_host', '');
+$smtpPort = get_setting('smtp_port', '587');
+$smtpEncryption = get_setting('smtp_encryption', 'tls');
+$smtpUsername = get_setting('smtp_username', '');
+$smtpFromName = get_setting('smtp_from_name', '');
+$smtpFromEmail = get_setting('smtp_from_email', '');
+$footerPrivacyLabel = get_setting('footer_privacy_label', 'Datenschutz');
+$footerPrivacyUrl = get_setting('footer_privacy_url', '');
+$footerPrivacyHtml = get_setting('footer_privacy_html', '');
+$footerImprintLabel = get_setting('footer_imprint_label', 'Impressum');
+$footerImprintUrl = get_setting('footer_imprint_url', '');
+$footerImprintHtml = get_setting('footer_imprint_html', '');
+$uploadExtensions = get_setting('upload_allowed_extensions', implode(', ', config()['uploads']['allowed_extensions']));
+$uploadMaxSize = get_setting('upload_max_size_mb', '10');
+$customThemes = list_custom_themes();
+$activeCustomTheme = get_setting('active_custom_theme', '');
+
+$cronFetch = cron_last_run_label(get_setting('cron_fetch_emails_last_run'));
+$cronSla = cron_last_run_label(get_setting('cron_check_sla_last_run'));
+$cronRecurring = cron_last_run_label(get_setting('cron_process_recurring_last_run'));
+$cronBackups = cron_last_run_label(get_setting('cron_process_backups_last_run'));
+$cronEscalations = cron_last_run_label(get_setting('cron_check_escalations_last_run'));
+$cronAnonymize = cron_last_run_label(get_setting('cron_anonymize_data_last_run'));
+$cronAutoClose = cron_last_run_label(get_setting('cron_auto_close_tickets_last_run'));
+
+
+$tab = $_GET['tab'] ?? 'allgemein';
+$pageTitle = 'Einstellungen';
+$activePage = 'settings';
+require __DIR__ . '/_header.php';
+?>
+
+
+
+= e($err) ?>
+
+
+
+ 'Allgemein',
+ 'design' => 'Design',
+ 'email' => 'E-Mail',
+ 'benachrichtigungen' => 'Benachrichtigungen',
+ 'uploads' => 'Uploads',
+ 'footer' => 'Footer',
+ 'cron' => 'Cron-Jobs',
+ 'datenschutz' => '🔒 Datenschutz',
+ ];
+ foreach ($tabs as $key => $label):
+ $active = $tab === $key;
+ ?>
+
+ = $label ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Firmenlogo
+
PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt.
+
+
+
+
+
+ = csrf_field() ?>
+ Entfernen
+
+
+
Kein Logo hinterlegt.
+
+
+ = csrf_field() ?>
+
+ Hochladen
+
+
+
+
+
Titelbild (Hero-Banner)
+
Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.
+
+
+
+ = csrf_field() ?>
+ Entfernen
+
+
+
Kein Titelbild hinterlegt.
+
+
+ = csrf_field() ?>
+
+ Hochladen
+
+
+
+
+
+
+
Startseiten-Inhalte
+
+ Legen Sie fest, welche zusätzlichen Bereiche auf der Kundenportal-Startseite angezeigt werden.
+
+
+ = csrf_field() ?>
+
+ >
+ Bereich „Beliebte Artikel“ auf der Startseite anzeigen
+
+ Speichern
+
+
+
+
+
Eigene Kundenportal-Themes
+
+ Laden Sie mehrere Designs als ZIP hoch und aktivieren Sie eines davon global für alle Kunden.
+ Das ZIP muss im Stammverzeichnis eine theme.css enthalten. Zulässig sind CSS,
+ PNG, JPG, GIF, WebP, WOFF und WOFF2; maximal 10 MB.
+ Autor und Version können im CSS-Kopf mit Author: und Version: angegeben werden.
+
+
+
+ = csrf_field() ?>
+
+ Theme-Paket
+
+
+ Theme hochladen
+
+
+ Der Anzeigename wird automatisch aus dem ZIP-Dateinamen übernommen, zum Beispiel
+ Classic-Support-Blue.zip → Classic Support Blue .
+ Ein erneuter Upload mit demselben Namen ersetzt das vorhandene Theme.
+
+
+
+
Noch keine eigenen Themes hochgeladen.
+
+
+
+
+
+
+
= installed_theme_thumb($theme, $__catThemes) ?>
+
= e($theme['name']) ?> · Version = e($theme['theme_version']) ?>
+
+
Aktiv
+
+
Von = e($theme['author']) ?>
+
+
+
+
+ = csrf_field() ?>
+ Standarddesign nutzen
+
+
+
+ = csrf_field() ?>
+
+ Aktivieren
+
+
+
+ = csrf_field() ?>
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
Themes aus dem Katalog
+
Fertige Designs direkt installieren. Prüfsumme und Signatur werden vor der Installation geprüft.
+
+
Katalog aktualisieren
+
+
+ = csrf_field() ?>
+ Katalog-Adresse (https)
+
+ Speichern
+ Katalog aktualisieren
+
+
+
+
+
= e($themeCatalog['error']) ?>
+
+
+ Theme Version Signatur
+
+
+
+ = catalog_thumb('theme', $t) ?>
= e($t['name']) ?> = e($t['description']) ?>
+
von = e($t['author']) ?>
+ = e($t['version']) ?>installiert: = e($__s2['version']) ?>
+ = $t['signature'] !== '' ? '✓ Signiert ' : 'Nicht signiert ' ?>
+
+
+ ✓ Installiert
+
+ = csrf_field() ?>= $__state['update'] ? 'Aktualisieren' : 'Installieren' ?>
+
+
+
+ Der Katalog enthält keine Themes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
E-Mail-Abruf (IMAP)
+
Postfächer, aus denen eingehende E-Mails als Tickets importiert werden. Es können mehrere Postfächer angelegt werden (z.B. ein eigenes je Abteilung) – jedes mit eigenem Server, Zugangsdaten und optionaler Standardabteilung.
+ query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn(); ?>
+
+ 0): ?>
+ ● = $imapAccountCount ?> aktive(s) Postfach/Postfächer eingerichtet
+
+ ○ Noch kein Postfach eingerichtet
+
+ · Letzter Abruf: = e($cronFetch) ?>
+
+
Postfächer verwalten →
+
+
+
+
+
+
+
+
Discord & Telegram
+
Mitarbeiter werden bei neuen Tickets und Antworten benachrichtigt. Ticket-Daten werden dabei an den jeweiligen Drittanbieter übermittelt.
+
+ 🔒 Webhook-URL und Bot-Token werden verschlüsselt gespeichert. Leer lassen = unverändert. Wert - löscht ihn.
+
+
+ = csrf_field() ?>
+ Discord Webhook-URL
+
+ Kanal-Einstellungen → Integrationen → Webhooks → Neuer Webhook
+ Telegram Bot-Token
+
+ Telegram Chat-ID
+
+ Bot über @BotFather erstellen, Chat-ID via @userinfobot ermitteln.
+
+ Speichern
+
+
+
+ = csrf_field() ?>
+ Testnachricht senden
+
+
+
+
+
+
+
Erlaubte Dateitypen
+
Gefährliche Typen (PHP, EXE, SH etc.) werden immer blockiert – unabhängig von dieser Liste.
+
+ = csrf_field() ?>
+ Erlaubte Datei-Endungen (kommagetrennt)
+
+
+
+ = e($ext) ?>
+
+
+ Maximale Dateigröße
+
+ MB
+
+ Speichern
+
+
+
+
+
+
+
Datenschutz & Impressum
+
Externer Link oder HTML-Inhalt (öffnet Popup). Ist ein Link gesetzt, wird das HTML-Feld ignoriert.
+
+ = csrf_field() ?>
+
+
+
🔒 Datenschutzerklärung
+
Beschriftung im Footer
+
+
Externer Link (optional)
+
+
HTML-Inhalt (Popup)
+
= e($footerPrivacyHtml) ?>
+
+
+
📄 Impressum
+
Beschriftung im Footer
+
+
Externer Link (optional)
+
+
HTML-Inhalt (Popup)
+
= e($footerImprintHtml) ?>
+
+
+ Speichern
+
+
+
+
+
+getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'
+ ? "SELECT COUNT(*) FROM tickets WHERE status IN ('closed','resolved')
+ AND updated_at < datetime('now', '-' || ? || ' months')
+ AND requester_email NOT LIKE 'anon_%'"
+ : "SELECT COUNT(*) FROM tickets WHERE status IN ('closed','resolved')
+ AND updated_at < DATE_SUB(NOW(), INTERVAL ? MONTH)
+ AND requester_email NOT LIKE 'anon_%'";
+ $previewStmt = $pdo->prepare($previewSql);
+ $previewStmt->execute([$retMonths]);
+ $previewCount = (int)$previewStmt->fetchColumn();
+?>
+
+
+
+
+
+
Status & Vorschau
+
+
+
+ = $retEnabled ? 'Aktiv – Anonymisierung läuft täglich via Cron' : 'Deaktiviert' ?>
+
+
+ Aktuell würden = $previewCount ?> Ticket(s) anonymisiert
+ (geschlossen/gelöst, älter als = $retMonths ?> Monate).
+
+
+
+
Was wird anonymisiert?
+
+ Name → Anonymisiert
+ E-Mail → anon_[hash]@deleted.local
+ IP-Adresse → 0.0.0.0
+ Ticket-Nachrichten → bleiben erhalten
+ Ticket-Nummer, Betreff, Status → bleiben erhalten
+
+
+
Crontab-Eintrag
+
0 3 * * * php /pfad/zu/deskhub/cron/anonymize_data.php
+
+
+ = csrf_field() ?>
+
+
+ ▶ Jetzt ausführen (= $previewCount ?> Tickets)
+
+
+
+
+
+
+
+
+
+
+
🔄 Automatischer Hintergrund-Betrieb (ohne Server-Cronjob)
+
+ Läuft komplett eingebaut, ganz ohne echten Server-Cronjob und ohne externen Dienst:
+ bei jeder Anmeldung/Nutzung im Backend und in der App wird im Hintergrund geprüft,
+ ob einer der 6 Jobs unten fällig ist, und automatisch ausgeführt. Solange regelmäßig jemand
+ das Backend oder die App nutzt, laufen alle Jobs von selbst dauerhaft weiter – ganz ohne
+ Einrichtung bei Strato oder einem externen Anbieter.
+
+
+
+ = csrf_field() ?>
+
+
+ onchange="this.form.submit()">
+ Automatischen Hintergrund-Betrieb aktiv halten
+
+
+
+ Status: = $pseudoCronEnabled ? '● aktiv ' : '○ deaktiviert ' ?>
+ · Letzte automatische Prüfung: = e($pseudoCronLastTick) ?>
+
+
+
+
+
+
Cron-Status
+
Jeder Job kann hier zusätzlich jederzeit sofort manuell gestartet werden.
+ $cronFetch,
+ 'check_sla' => $cronSla,
+ 'check_escalations' => $cronEscalations,
+ 'process_recurring' => $cronRecurring,
+ 'process_backups' => $cronBackups,
+ 'anonymize_data' => $cronAnonymize,
+ 'auto_close_tickets'=> $cronAutoClose,
+ ];
+ ?>
+
+
+ $jobMeta): ?>
+
+ = e($jobMeta['label']) ?>
+ = e($cronLastRun[$jobKey]) ?>
+
+
+ = csrf_field() ?>
+
+
+ Jetzt ausführen
+
+
+
+
+
+
+
+
+
Echter Server-Cronjob
+
+
+
+ ✅ Dieser Server unterstützt eine automatische Installation (Shell-Zugriff erkannt).
+ DeskHub trägt dafür einen klar markierten Block in die Crontab des Server-Benutzers ein –
+ alle sonstigen vorhandenen Einträge bleiben unangetastet.
+
+
+ Status:
+
+ ● Installiert
+
+ ○ Nicht installiert
+
+
+
+
+ = csrf_field() ?>
+
+ = $crontabInstalled ? 'Neu installieren / aktualisieren' : 'Jetzt installieren' ?>
+
+
+
+ = csrf_field() ?>
+
+ Entfernen
+
+
+
+
+
+ ❌ Auf diesem Server nicht möglich: = e($crontabCheck['reason']) ?>
+ Typisch bei Shared-Hosting (z.B. den meisten Strato-Webhosting-Paketen). Der oben aktive
+ automatische Hintergrund-Betrieb deckt das in der Regel bereits ab – alternativ unten die
+ Zeilen manuell im Hosting-Kundenlogin eintragen, falls dort ein Cronjob-Manager verfügbar ist.
+
+
+
+ Crontab-Zeilen anzeigen
+ = e(crontab_marker_block()) ?>
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/site_pages.php b/DeskHub-Webseite/staff/site_pages.php
new file mode 100644
index 00000000..580eb01c
--- /dev/null
+++ b/DeskHub-Webseite/staff/site_pages.php
@@ -0,0 +1,167 @@
+prepare('SELECT COUNT(*) FROM site_pages WHERE slug = ? AND id <> ?');
+ $dup->execute([$slug, $id]);
+ if ((int) $dup->fetchColumn()) $errors[] = 'Dieser Slug wird bereits verwendet.';
+ }
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $pdo->prepare('INSERT INTO site_pages (slug,title,content,show_in_nav,is_active,sort_order,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)')
+ ->execute([$slug, $title, $content, $showNav, $isActive, $sortOrder, db_now(), db_now()]);
+ audit_log($staff['id'], "Seite erstellt: {$title}");
+ flash('success', 'Seite erstellt.');
+ } else {
+ $pdo->prepare('UPDATE site_pages SET slug=?,title=?,content=?,show_in_nav=?,is_active=?,sort_order=?,updated_at=? WHERE id=?')
+ ->execute([$slug, $title, $content, $showNav, $isActive, $sortOrder, db_now(), $id]);
+ audit_log($staff['id'], "Seite aktualisiert: {$title}");
+ flash('success', 'Änderungen gespeichert.');
+ }
+ redirect('/staff/site_pages.php');
+ }
+ }
+
+ if ($action === 'delete') {
+ $pdo->prepare('DELETE FROM site_pages WHERE id = ?')->execute([(int) $_POST['id']]);
+ audit_log($staff['id'], 'Seite gelöscht: #' . (int) $_POST['id']);
+ flash('success', 'Seite gelöscht.');
+ redirect('/staff/site_pages.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$pages = $pdo->query('SELECT * FROM site_pages ORDER BY sort_order, title')->fetchAll();
+$editPage = null;
+if ($editId) { $es = $pdo->prepare('SELECT * FROM site_pages WHERE id = ?'); $es->execute([$editId]); $editPage = $es->fetch() ?: null; }
+
+$pageTitle = 'Seiten-Verwaltung'; $activePage = 'site_pages';
+require __DIR__ . '/_header.php';
+?>
+
+= e($err) ?>
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editPage[$col] ?? $default);
+ $curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editPage['is_active'] ?? 1);
+ $showNavChecked = $_POST ? !empty($_POST['show_in_nav']) : (bool) ($editPage['show_in_nav'] ?? 1);
+ // Inhalt wird beim Absenden als bereinigtes HTML gespeichert; bei Fehlern den eingegebenen Text behalten
+ $contentHtml = $_POST ? sanitize_staff_html((string) ($_POST['content'] ?? '')) : (string) ($editPage['content'] ?? '');
+?>
+
+ = $editPage ? 'Seite aktualisieren' : 'Neue Seite' ?>
+ — = e($editPage['title']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+ 'Seite', 'content' => 'Inhalt']); ?>
+
+
+
+
+
+
+ B
+ I
+ U
+ •
+ 1.
+ 🔗
+
+
= $contentHtml ?>
+
+
+
+
+
+
= $editPage ? 'Änderungen speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+Eigene Inhaltsseiten für das Kundenportal (z. B. Impressum, Datenschutz, Willkommen). Erreichbar unter /page/{slug}.
+
+
+
+ Titel URL Navigation Status Erstellt Zuletzt aktualisiert
+
+
+
+ = e($p['title']) ?>
+ /page/= e($p['slug']) ?>
+ = $p['show_in_nav'] ? '✓' : '–' ?>
+ = $p['is_active'] ? 'Aktiv' : 'Deaktiviert' ?>
+ = e(ui_date($p['created_at'] ?? null)) ?>
+ = e(ui_date($p['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+ Noch keine Seiten angelegt.
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/sla_plans.php b/DeskHub-Webseite/staff/sla_plans.php
new file mode 100644
index 00000000..9595298c
--- /dev/null
+++ b/DeskHub-Webseite/staff/sla_plans.php
@@ -0,0 +1,137 @@
+ $_POST['name'] ?? '', 'response_hours' => $_POST['response_hours'] ?? 0, 'resolution_hours' => $_POST['resolution_hours'] ?? 0,
+ 'is_active' => ($_POST['status'] ?? 'active') === 'active', 'notes' => $_POST['notes'] ?? '',
+ ]);
+ audit_log($staff['id'], ($action === 'create' ? 'SLA-Plan erstellt: ' : 'SLA-Plan aktualisiert: ') . trim($_POST['name'] ?? ''));
+ flash('success', $action === 'create' ? 'SLA-Plan erstellt.' : 'Änderungen gespeichert. Sie gelten für neue Tickets und bei Prioritätsänderung.');
+ redirect('/staff/sla_plans.php');
+ } catch (AdminModelException $e) {
+ $errors[] = $e->getMessage();
+ }
+ }
+
+ if ($action === 'delete') {
+ $deleted = sla_plan_delete($pdo, (int) $_POST['id']);
+ audit_log($staff['id'], "SLA-Plan gelöscht: {$deleted}");
+ flash('success', 'SLA-Plan gelöscht. Betroffene Abteilungen und Themen nutzen wieder die Zeiten der Priorität.');
+ redirect('/staff/sla_plans.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$plans = $pdo->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();
+$editPlan = null;
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editPlan = $stmt->fetch() ?: null;
+}
+$hours = fn(int $h) => $h >= 24 && $h % 24 === 0 ? ($h / 24) . ' Tag' . ($h / 24 === 1 ? '' : 'e') : $h . ' Std';
+
+$pageTitle = 'SLA-Pläne';
+$activePage = 'sla_plans';
+require __DIR__ . '/_header.php';
+?>
+
+= e($err) ?>
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editPlan[$col] ?? $default);
+ $curActive = $_POST ? (($_POST['status'] ?? 'active') === 'active') : (bool) ($editPlan['is_active'] ?? 1);
+?>
+
+ = $editPlan ? 'SLA-Plan aktualisieren' : 'Neuer SLA-Plan' ?>
+ — = e($editPlan['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+ 'SLA-Plan']); ?>
+
+
Planinformationen
+
Name *
+
+
+
+
Der Plan ersetzt die Zeiten der Priorität für Tickets, deren Hilfethema oder Abteilung ihn zugeordnet hat (Hilfethema vor Abteilung). Mit aktivierten Geschäftszeiten zählen nur Öffnungszeiten.
+
Administratornotizen – Interne Notizen
+
= e($v('notes', 'notes')) ?>
+
+
+
+
= $editPlan ? 'Änderungen speichern' : 'Erstellen' ?>
+
Abbrechen
+
+
+
+
+
+
+Benannte Reaktions- und Lösungszeiten, die du Abteilungen und Hilfethemen zuordnest. Ohne Zuordnung gelten die Zeiten der Priorität .
+
+
+
+ Name Status Reaktion Lösung Abteilungen Hilfethemen Zuletzt aktualisiert
+
+
+
+ = e($p['name']) ?>
+ = $p['is_active'] ? 'Aktiv' : 'Deaktiviert' ?>
+ = e($hours((int) $p['response_hours'])) ?>
+ = e($hours((int) $p['resolution_hours'])) ?>
+ = (int) $p['dept_count'] ?>
+ = (int) $p['topic_count'] ?>
+ = e(ui_date($p['updated_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+ Noch keine SLA-Pläne angelegt.
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/staff_manage.php b/DeskHub-Webseite/staff/staff_manage.php
new file mode 100644
index 00000000..a99b47e5
--- /dev/null
+++ b/DeskHub-Webseite/staff/staff_manage.php
@@ -0,0 +1,405 @@
+query('SELECT * FROM departments ORDER BY name')->fetchAll();
+$customRoles = $pdo->query('SELECT * FROM roles ORDER BY name')->fetchAll();
+$teams = $pdo->query('SELECT id, name, is_active FROM teams ORDER BY name')->fetchAll();
+
+$permLabels = [
+ 'reply_ticket' => 'Tickets beantworten',
+ 'close_ticket' => 'Tickets schließen/lösen',
+ 'delete_ticket' => 'Tickets löschen',
+ 'transfer_ticket' => 'Tickets übertragen',
+ 'post_note' => 'Interne Notizen schreiben',
+ 'assign_ticket' => 'Tickets zuweisen',
+ 'manage_canned' => 'Textbausteine verwalten',
+ 'view_reports' => 'Berichte einsehen',
+ 'manage_kb' => 'Wissensdatenbank verwalten',
+ 'create_ticket' => 'Tickets im Namen von Kunden erstellen',
+] + array_diff_key(admin_section_permissions(), ['manage_kb' => 1]);
+
+/** Setzt die zusätzlichen Abteilungen (Erweiterter Zugriff) neu. */
+function save_staff_access(PDO $pdo, int $id, ?int $primaryDeptId, array $post): void
+{
+ $selected = array_unique(array_filter(array_map('intval', $post['extra_departments'] ?? [])));
+ $pdo->prepare('DELETE FROM staff_department WHERE staff_id = ?')->execute([$id]);
+ $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));
+ foreach ($selected as $dId) {
+ if ($dId === (int) $primaryDeptId) continue; // Hauptabteilung wird nicht doppelt gespeichert
+ $rid = (int) ($post['role_' . $dId] ?? 0);
+ $ins->execute([$id, $dId, !empty($post['notify_' . $dId]) ? 1 : 0, in_array($rid, $validRoles, true) ? $rid : null]);
+ }
+}
+
+/** Setzt die Team-Mitgliedschaften eines Mitarbeiters neu. */
+function save_staff_teams(PDO $pdo, int $id, array $teamIds): void
+{
+ $pdo->prepare('DELETE FROM team_members WHERE staff_id = ?')->execute([$id]);
+ $ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)');
+ foreach (array_unique(array_filter(array_map('intval', $teamIds))) as $tid) $ins->execute([$tid, $id]);
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ verify_csrf();
+ $action = $_POST['action'] ?? '';
+
+ if ($action === 'create' || $action === 'update') {
+ $id = (int) ($_POST['id'] ?? 0);
+ $name = trim($_POST['name'] ?? '');
+ $email = trim($_POST['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 = ($_POST['role'] ?? '') === 'admin' ? 'admin' : 'agent';
+ } elseif ($action === 'update') {
+ $currentRoleStmt = $pdo->prepare('SELECT role FROM staff WHERE id = ?');
+ $currentRoleStmt->execute([$id]);
+ $role = $currentRoleStmt->fetchColumn() === 'admin' ? 'admin' : 'agent';
+ } else {
+ $role = 'agent';
+ }
+ $roleId = ($_POST['role_id'] ?? '') !== '' ? (int) $_POST['role_id'] : null;
+ $deptId = ($_POST['department_id'] ?? '') !== '' ? (int) $_POST['department_id'] : null;
+ $isActive = ($_POST['status'] ?? 'active') === 'active' ? 1 : 0;
+ $password = $_POST['password'] ?? '';
+ $signature = sanitize_staff_html(trim($_POST['signature'] ?? ''));
+
+ if ($name === '') $errors[] = 'Bitte einen Namen angeben.';
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors[] = 'Bitte eine gültige E-Mail-Adresse angeben.';
+ if ($action === 'create' || $password !== '') {
+ $pwErr = validate_password_strength($password);
+ if ($pwErr) $errors[] = $pwErr;
+ }
+ if ($action === 'update' && $id === (int) $me['id'] && !$isActive) $errors[] = 'Sie können Ihr eigenes Konto nicht deaktivieren.';
+ $dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id <> ?');
+ $dup->execute([$email, $id]);
+ if ($dup->fetchColumn() > 0) $errors[] = 'Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits.';
+
+ if (!$errors) {
+ if ($action === 'create') {
+ $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()]);
+ $id = (int) $pdo->lastInsertId();
+ audit_log($me['id'], "Mitarbeiter angelegt: {$name} ({$email})");
+ flash('success', 'Mitarbeiter angelegt.');
+ } else {
+ $sql = 'UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=?';
+ $params = [$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive];
+ if ($password !== '') { $sql .= ', password_hash=?'; $params[] = password_hash($password, PASSWORD_DEFAULT); }
+ $params[] = $id;
+ $pdo->prepare($sql . ' WHERE id=?')->execute($params);
+ audit_log($me['id'], "Mitarbeiter aktualisiert: {$name} ({$email})");
+ flash('success', 'Änderungen gespeichert.');
+ }
+ save_staff_access($pdo, $id, $deptId, $_POST);
+ if ($canManageTeams) save_staff_teams($pdo, $id, (array) ($_POST['teams'] ?? []));
+ redirect('/staff/staff_manage.php');
+ }
+ }
+
+ // Eigenständiger Zugriffs-Speicher-Aufruf (Rückwärtskompatibilität)
+ if ($action === 'save_access') {
+ $id = (int) $_POST['id'];
+ $stmt = $pdo->prepare('SELECT department_id FROM staff WHERE id = ?');
+ $stmt->execute([$id]);
+ save_staff_access($pdo, $id, (int) $stmt->fetchColumn(), $_POST);
+ audit_log($me['id'], "Abteilungszugriff geändert für Mitarbeiter #{$id}");
+ flash('success', 'Abteilungszugriff gespeichert.');
+ redirect('/staff/staff_manage.php?edit=' . $id . '&tab=access');
+ }
+
+ if ($action === 'delete') {
+ $id = (int) $_POST['id'];
+ if ($id === (int) $me['id']) {
+ flash('error', 'Sie können Ihr eigenes Konto nicht löschen.');
+ } else {
+ $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}");
+ flash('success', 'Mitarbeiter gelöscht.');
+ }
+ redirect('/staff/staff_manage.php');
+ }
+}
+
+$isNew = isset($_GET['new']);
+$editId = (int) ($_GET['edit'] ?? 0);
+$showForm = $isNew || $editId || ($errors && in_array($_POST['action'] ?? '', ['create', 'update'], true));
+
+$editStaff = null;
+$extraAccess = []; // department_id => ['notify' => 0/1, 'role_id' => ?int]
+$memberTeams = [];
+if ($editId) {
+ $stmt = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
+ $stmt->execute([$editId]);
+ $editStaff = $stmt->fetch() ?: null;
+ if ($editStaff) {
+ $stmt = $pdo->prepare('SELECT department_id, notify, role_id FROM staff_department WHERE staff_id = ?');
+ $stmt->execute([$editId]);
+ foreach ($stmt->fetchAll() as $row) $extraAccess[(int) $row['department_id']] = ['notify' => (int) $row['notify'], 'role_id' => $row['role_id'] !== null ? (int) $row['role_id'] : null];
+ $stmt = $pdo->prepare('SELECT team_id FROM team_members WHERE staff_id = ?');
+ $stmt->execute([$editId]);
+ $memberTeams = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
+ }
+}
+if ($_POST && $errors) { // eingegebene Auswahl bei Fehlern erhalten
+ $extraAccess = [];
+ foreach (array_filter(array_map('intval', $_POST['extra_departments'] ?? [])) as $dId) {
+ $extraAccess[$dId] = ['notify' => !empty($_POST['notify_' . $dId]) ? 1 : 0, 'role_id' => ($_POST['role_' . $dId] ?? '') !== '' ? (int) $_POST['role_' . $dId] : null];
+ }
+ $memberTeams = array_map('intval', (array) ($_POST['teams'] ?? []));
+}
+
+$allStaff = $pdo->query(
+ 'SELECT s.*, d.name AS department_name,
+ (SELECT COUNT(*) FROM team_members tm WHERE tm.staff_id = s.id) AS team_count
+ FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name'
+)->fetchAll();
+$fmt = fn(?string $dt) => $dt ? date('d.m.Y', strtotime($dt)) : '–';
+
+$pageTitle = 'Mitarbeiter';
+$activePage = 'staff';
+require __DIR__ . '/_header.php';
+?>
+
+
+ = e($err) ?>
+
+
+
+ $_POST[$k] ?? $default;
+ $v = fn(string $k, string $col, $default = '') => $f($k, $editStaff[$col] ?? $default);
+ $curStatus = $_POST ? ($_POST['status'] ?? 'active') : (($editStaff['is_active'] ?? 1) ? 'active' : 'locked');
+ $curRole = (string) $v('role', 'role', 'agent');
+ $curRoleId = (string) $v('role_id', 'role_id');
+ $curDept = (string) $v('department_id', 'department_id');
+ $startTab = in_array($_GET['tab'] ?? '', ['account', 'access', 'perms', 'teams'], true) ? $_GET['tab'] : 'account';
+ $rolePerms = [];
+ foreach ($customRoles as $r) $rolePerms[$r['id']] = json_decode($r['permissions'], true) ?: [];
+ $agentDefaults = default_permissions('agent');
+ $adminDefaults = default_permissions('admin');
+?>
+
+ = $editStaff ? 'Agenten verwalten' : 'Neuer Agent' ?>
+ — = e($editStaff['name']) ?>
+
+
+
+ = csrf_field() ?>
+
+
+
+
+ Konto
+ Zugang
+ Berechtigungen
+ Teams
+
+
+
+
+
+
+
+
Zugang
+
Wähle die Abteilungen, zu denen der Agent Zugang haben soll.
+
+
Hauptabteilung *
+
+
+ –
+
+ >= e($d['name']) ?>
+
+
+ Rolle: – (Reiter „Berechtigungen“)
+
+
+
Erweiterter Zugriff
+
+
+
+
+
+
+
+
+
+
Berechtigungen
+
Kontotyp
+
>
+ >Mitarbeiter
+ >Administrator (alle Rechte)
+
+
+
+
Nur Administratoren können die Administrator-Rolle vergeben.
+
+
+
Rolle (für Mitarbeiter)
+
+ – Standard-Mitarbeiterrechte –
+
+ >= e($cr['name']) ?>
+
+
+
Rollen legst du unter Rollen an.
+
+
Wirksame Rechte
+
+
+
+
+
+
Teams
+
+
Es sind noch keine Teams angelegt. Teams legst du unter Teams an.
+
+
Der Agent ist Mitglied in folgenden Teams.= $canManageTeams ? '' : ' (Zum Ändern fehlt das Recht „Teams verwalten“.)' ?>
+
+
+ = $canManageTeams ? '' : 'disabled' ?>>
+ = e($t['name']) ?>= $t['is_active'] ? '' : ' (deaktiviert) ' ?>
+
+
+
+
+
+
+
= $editStaff ? 'Änderungen speichern' : 'Agent anlegen' ?>
+
Abbrechen
+
+
+
+
+
+
+
+
+
+
+
+ Name E-Mail Typ Status Hauptabteilung Teams Erstellt
+
+
+
+ = e($s['name']) ?>
+ = e($s['email']) ?>
+ = $s['role'] === 'admin' ? 'Administrator' : 'Mitarbeiter' ?>
+ = $s['is_active'] ? 'Aktiv' : 'Gesperrt' ?>
+ = e($s['department_name'] ?? '–') ?>
+ = (int) $s['team_count'] ?>
+ = e($fmt($s['created_at'] ?? null)) ?>
+
+ Bearbeiten ·
+
+ = csrf_field() ?>
+
+
+ Löschen
+
+
+
+
+
+
+
+
+
+
diff --git a/DeskHub-Webseite/staff/sw-staff.js b/DeskHub-Webseite/staff/sw-staff.js
new file mode 100644
index 00000000..2b0a01eb
--- /dev/null
+++ b/DeskHub-Webseite/staff/sw-staff.js
@@ -0,0 +1,73 @@
+/**
+ * DeskHub Service Worker – Mitarbeiterbereich (scope: /staff/)
+ */
+const CACHE = 'deskhub-staff-v2';
+const OFFLINE_URL = '/offline.html';
+const PRECACHE = ['/offline.html', '/assets/css/style.css'];
+
+self.addEventListener('install', e => {
+ e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)));
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', e => {
+ e.waitUntil(
+ caches.keys().then(keys =>
+ Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
+ )
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', e => {
+ if (e.request.method !== 'GET') return;
+
+ if (e.request.url.match(/\.(css|js|woff2?|png|svg|ico|webp)(\?.*)?$/)) {
+ e.respondWith(
+ caches.match(e.request).then(cached =>
+ cached || fetch(e.request).then(resp => {
+ caches.open(CACHE).then(c => c.put(e.request, resp.clone()));
+ return resp;
+ })
+ )
+ );
+ return;
+ }
+
+ e.respondWith(
+ fetch(e.request).catch(() => caches.match(OFFLINE_URL))
+ );
+});
+
+// ── Push Notifications ────────────────────────────────────────────────────────
+self.addEventListener('push', e => {
+ let data = { title: 'DeskHub', body: 'Neue Benachrichtigung', url: '/staff/', icon: '/assets/icons/icon-192.png' };
+ try { data = Object.assign(data, e.data.json()); } catch {}
+ e.waitUntil(
+ self.registration.showNotification(data.title, {
+ body: data.body,
+ icon: data.icon,
+ badge: data.badge || '/assets/icons/icon-192.png',
+ data: { url: data.url },
+ vibrate: [150, 50, 150],
+ tag: 'deskhub-' + (data.url || ''),
+ renotify: true,
+ })
+ );
+});
+
+self.addEventListener('notificationclick', e => {
+ e.notification.close();
+ const url = e.notification.data?.url || '/staff/';
+ e.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
+ for (const c of list) {
+ if (c.url.includes('/staff/') && 'focus' in c) {
+ c.navigate(url);
+ return c.focus();
+ }
+ }
+ return clients.openWindow(url);
+ })
+ );
+});