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.
+ + + + + + + + + + + + +
ZeitpunktMitarbeiterAktion
+ 1): ?> +
+ Seite von + 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]): ?> + + +
+ +
+
+
+ + +
+
Team-Übersicht
+
+ +
+
+ + +
+
+
+ + 🌴 +
+
+
+
+ + ⚙️ + +
+ +
+
+ + + + + +
+

Schichtplan:

+

+ Definiere an welchen Tagen und zu welchen Zeiten dieser Mitarbeiter verfügbar ist. + Tickets werden außerhalb der Schicht nicht automatisch zugewiesen. +

+
+ + + + + + + + + + + + + + + + + + + + + + +
TagAktivVonBis
+ + onchange="toggleDay(, this.checked)"> + + required> + + required> +
+
+ + Abbrechen +
+
+
+ + + + + + 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

+
+ + + +
+
+ + +
+
Backups vorhanden
+
MB
Gesamtgröße
+
Letztes Backup
+
Letztes Auto-Backup
+
+ + +
+

Vorhandene Backups

+ +

Letzte Prüfung: – : +

+ + +
Noch keine Backups vorhanden.
+ + + + + + + + + + + + + +
DateinameGrößeErstellt
MB + ⬇️ Download + +
+
+
♻️ Wiederherstellen +
+ +

Achtung: Der aktuelle Stand der Datenbank wird durch den Stand dieses Backups ersetzt. Vorher wird automatisch ein Sicherheits-Backup erstellt. Programmdateien und die Konfiguration bleiben unverändert.

+ + $dl): ?> + + + +
+ +
+ + + + +
+
+ +
+ +
+ + +
+

⏰ Automatischer Zeitplan

+

+ Cron-Job einrichten: php /cron/process_backups.php
+ Letzter Lauf: +

+
+ + + + + + +

Ältere Backups werden automatisch gelöscht wenn das Limit erreicht wird.

+
+ +
+
+
+ + +
+

📡 FTP-Upload (optional)

+

Backups werden nach der Erstellung automatisch per FTP übertragen.

+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ + +
+
+ +
+
+
+ + +
+
+ +
+ +
+ + +
+

ℹ️ Hinweise

+ +
+ + + 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.

+ + +
+ + +
+ + +
+
+

Gesperrte Einträge ()

+ + + + + + + + + + + + + + + + +
TypWertGrundGesperrt am
'.e($b['added_by_name']) : '' ?> +
+ + + + +
+
Sperrliste ist leer.
+
+
+ + +
+ + +
+

Einzeln sperren

+
+ + + + + + + + +
+
+
+ + +
+

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.

+ + +
+ + + +
+ 'Wochenzeiten', 'holidays' => 'Feiertage (' . count($holidays) . ')']); ?> + +
+

Öffnungszeiten je Wochentag

+
+ + + + + + + + + 0, 'open_time' => '09:00:00', 'close_time' => '17:00:00']; + ?> + + + + + + + + +
TagGeöffnetVonBis
>
+ +
+ +
+
+
+ + + +
+ + + + 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.

+ + +
+ + +
+
+ + +
+

+
+ + + + + + + + +
+ Verfügbare Variablen — klicken zum Einfügen: +
+ + + +
+
+ +
+ + Abbrechen +
+
+
+ + +
+

Alle Textbausteine

+ Neuer Textbaustein
+ + + + + + + + + + + + +
TitelAbteilungText (Vorschau)
$0', e($preview)); + ?> + Bearbeiten · +
+ + + + +
+
+
+
+
+ + + + 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
+
+ + + + +
+
+
+
+
+
+
+ + +
+ +
Noch keine Nachrichten – schreib etwas!
+ + +
+ +
+ +
+ +
+
+
+
+
+
+ +
+ + +
+
+ +
+ + + +
+
+
+ + + + +
+
+ + +
+ + +
+
Online Mitarbeiter
+ +
+ +
+ + + + + + 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.

+ + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + +
BezeichnungTypHilfe-ThemaPflichtfeldAktiv
+ Bearbeiten · +
+ + + + +
+
Noch keine individuellen Felder angelegt.
+
+
+
+
+

+
+ + + + + + + + + + +
+ + +
+ + + +

Auf „Alle Themen“ belassen, damit das Feld unabhängig vom gewählten Anliegen erscheint.

+ + + + + + + +
+ + Abbrechen +
+
+
+
+
+ + 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'; +?> + +
+ + + $_POST[$k] ?? $default; + $v = fn(string $k, string $col, $default = '') => $f($k, $editList[$col] ?? $default); +?> +

+ + — +

+ +
+ + + + + 'Liste', 'items' => 'Einträge']); ?> + +
+

Listeninformationen

+ + + + +

Administratornotizen – Interne Notizen, sichtbar für alle Administratoren

+ +

Wiederverwendbare Auswahllisten für Ticket-Felder: Wähle in einem Ticket-Feld den Typ „Liste“ und verknüpfe diese Liste.

+
+ + + +
+ + Abbrechen +
+
+ + + +
+

Benutzerdefinierte Listen

+ Neue Liste hinzufügen +
+

Wiederverwendbare Auswahllisten für Ticket-Felder. Wähle in einem Ticket-Feld den Typ „Liste“ und verknüpfe eine dieser Listen.

+ +
+ + + + + + + + + + + + + + +
NameEinträgeErstelltZuletzt aktualisiert
+ Bearbeiten · +
+ + + + +
+
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'; +?> + +
+
+

Gast

+ +
+ ← Zurück +
+ +
Gast-Anfrager ohne eigenes Kundenkonto – Notizen und Kontaktdaten-Bearbeitung sind daher nicht verfügbar.
+ + +
+ + +
+ + +
+

Tickets ()

+ +
Keine Tickets vorhanden.
+ + + + + + + + + + + + + + + +
TicketBetreffStatusPrioritätMitarbeiterErstellt
#
+ +
+ + + +
+

Interne Notizen

+
+ + + +
+
+ +

Noch keine Notizen.

+ + +
+
+
·
+
+
+
+ + + + +
+
+ + +
+ + +
+ + +
+ + +
+
Gesamt
+
Offen
+
Gelöst
+
Seit
+
+ + +
+
Kontaktdaten
+ + +
+ +
+

Ohne Konto nicht bearbeitbar.

+ +
+ + + + + + + +
+ + +
+ + +
+ +
+ +
+
+ + 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.

+ +
+
+ + +
+
+ +
Zurücksetzen
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameE-MailIP-AdresseTypOrganisationTicketsSeitStatus
+ –' ?> + + Gast' + : 'Konto' ?> + + + – (kein Konto) + +
+ + + + +
+ +
+ 0): ?> + + + 0 + + + + Gesperrt + + Aktiv + + + +
+ + + + +
+ +
werden abgewiesen.');"> + + + + + +
+ +
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'; +?> + + +
+ + + + $_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; +?> +

+ + — +

+ +
+ + + + +
+ + +
+ +
+

Abteilungsinformationen

+ + + + +
+ + +
+ + + + + + + + + +

Adressen, Priorität und Postfach-Abruf pflegst du unter E-Mail-Adressen.

+ + + + + + +

Administratornotizen – Interne Notizen, sichtbar für alle Administratoren

+ +
+ + + +
+ + Abbrechen +
+
+ + + + + +
+

Abteilungen

+ Neue Abteilung hinzufügen +
+ +
+ + + + + + + + + + + + + + + + + + + +
NameStatusMitgliederAbteilungsleiterE-MailErstelltZuletzt aktualisiert
(Standard)' : '' ?> + Bearbeiten · +
+ + + + +
+
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'; +?> + + +
+ + + + $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']); +?> +

+ + — <> +

+ +
+ + + + +
+ + + +
+ +
+

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.

+ + + + + +
+ + +
+

Eine deaktivierte Adresse wird weder für das Routing noch als Absender verwendet; der Abruf pausiert.

+ +

Einstellungen bei neuem Ticket

+ + + + + + + + +

Interne Notizen – Sei offen, sie sind vertraulich

+ +
+ + + + + +
+ + Abbrechen +
+
+ + +
+ +
+ + + + +
+ +
+

Tests verwenden die gespeicherten Werte.

+ + + + + + +
+

E-Mail-Adressen

+ Neue E-Mail-Adresse +
+

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-AdresseAnzeigenameStatusAbteilungPrioritätAbrufEigener SMTPZuletzt aktualisiert
(Standard)' : '' ?> + Bearbeiten · +
+ + + + +
+
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).

+
+ + + + + + + +
+
+
+ + + 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.

+ + +
+ + + $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; +?> +
+

angepasst

+

Verfügbare Platzhalter: {}

+
+ + + + + + +
+ + + + +
+
+
+ + + 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

+ + Neue Regel +
+ +

+ Eskalationsregeln werden stündlich per Cron geprüft (cron/check_escalations.php). + Jede Regel wird pro Ticket nur einmal angewendet. +

+ +
+ + + + +
+

+
+ + + + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+
Auslöser
+
+
+ + +
+
+ + +
+
+
+ +
+
Bedingungen (optional – leer = alle Tickets)
+
+
+ + + Kommagetrennt +
+
+ + +
+
+ + +
+
+
+ +
+
Aktionen
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + Abbrechen +
+
+
+ + + +
+ +
Noch keine Eskalationsregeln. Erstellen Sie eine neue Regel mit dem Button oben.
+ + + + + + + + + + + + + + + + + + + + + + + + +
NameAuslöserAktionenAngewendetStatus
+ + + ( Std) + + +
+ + +
+ +
+ + + + + + + Nur benachrichtigen' ?> + × + + + + + + Bearbeiten · +
+ + + + +
· +
+ + + + +
+
+ +
+ +
+

Crontab-Eintrag

+
0 * * * * php /pfad/zu/deskhub/cron/check_escalations.php
+

Stündliche Prüfung. Letzte Ausführung:

+
+ + + + 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.
+ +
+ + +
Falls ein aktives Mitarbeiterkonto mit dieser E-Mail-Adresse existiert, wurde soeben ein Link zum Zurücksetzen versendet.
+

← Zur Anmeldung

+ +
+ + + +
+ +
+
+

← 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'; +?> + + +
+ + + + $_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'])) : []; +?> +

+ + — +

+ +
+ + + + +
+ + + +
+ +
+ + + + + +
+ + +
+

Öffentliche Themen sehen auch Kunden im Portal; interne nur Mitarbeiter.

+ + + + +
+ + + + + +
+ + Abbrechen +
+
+ + + + + +
+

Hilfethemen

+
+
+ + +
+ Neues Hilfethema hinzufügen +
+
+

Hilfethemen erscheinen im Kundenportal als Auswahl bei der Ticket-Erstellung und setzen automatisch Abteilung und Priorität.

+ +
+ + + + + + + + + + + + + + + + + + + +
HilfethemaStatusTypPrioritätAbteilungZuletzt aktualisiertErstellt
+ Bearbeiten · +
+ + + + +
+
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. +

+ + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + +
PostfachServerStandardabteilungAktivLetzter Abruf

: +
+ + + + +
· + Bearbeiten · +
+ + + + +
+
Noch kein Postfach angelegt.
+
+
+
+
+

+
+ + + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + + + + +
+ + +
+
+ + +
Wird verwendet, wenn ein eingegangenes E-Mail-Ticket als Spam markiert wird.
+
+ +
+ + 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'; +?> + +
+

osTicket Import

+ ← Einstellungen +
+ +
+ 'Hochladen','analyze'=>'Analysieren','2'=>'Optionen','run'=>'Import'] as $s=>$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)

+
+ + + + +
+
+

Option 2: Direkt hochladen (in 5-MB-Teilen)

+ + + + +
+ + +
+

Analysiere: ( 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): ?> +
+
0
+
+
+ +
+ +
+ + + + +
+

Was soll importiert werden?

+
+ 'Kunden','tickets'=>'Tickets','threads'=>'Nachrichten','departments'=>'Abteilungen','topics'=>'Hilfethemen','attachments'=>'Anhänge'] as $k=>$l): ?> +
+
+
+
+ +
+
+ + + ['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]): ?> + + +

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.

+
+ + Neu hochladen + + +
+
+ +
+ + + +
+

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; ?> +
+
Wartend...
+
+
+
+
+ + +
+ + + + 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

+ + +
+ + +
+
+ + +
+

+
+ + + + + + + + + + +
+ + Abbrechen +
+
+
+ + +
+ + + + + + + + + + + + + + +
TitelKategorieStatusAufrufe
Live' : 'Entwurf' ?> + Bearbeiten · +
+ + + + +
+
+
+
+ +
+
+

Kategorien

+ +
+ +
+ + + + +
+
+ +
+ + + + +
+
+
+
+
+ + 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

+
Live-Chat ist derzeit deaktiviert. Aktivieren unter Einstellungen → Allgemein.
+ + + + +
+
+
Aktive Live-Chats ()
+
+ +
Aktuell keine offenen Live-Chats.
+ + + +
+ ' : '' ?> + +
+
+
+ +
+
+ +
+ +
+ +
Kein Live-Chat ausgewählt
+
+ +
+
+
+
·
+
+
+ + + + Im Ticket öffnen ↗ +
+
+ +
+ 🔒 Wird gerade von    bearbeitet – Sie sehen live mit, können hier aber nicht schreiben. +
+ +
+ +
+
+ +
+ + + 📎 + +
+
+
+ +
+ + +
Dieser Chat wurde geschlossen.
+ +
Nur Lesezugriff, solange diesen Chat bearbeitet.
+ +
+ + +
+ + +
+
+ + + + + + + + + + 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) ?> +"> + + + + + + + + + + + + +
+
+ + + + +
+ ← Zum Kundenportal +
+ + +
+
+

Mitarbeiterbereich

+
Anmeldung für Support-Mitarbeiter und Administratoren.
+ + +
+ + +
+ + + +

Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.

+
+ + + + +
+ +
+
+

← Zurück zum Login

+ +
+ + + + + + +
+ Passwort vergessen? +
+
+ +
+
+ +
+
+ + + + + + 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.
+ + + + + + + + + + + + + + + + + + + + +
TicketBetreffErwähnt vonDatum
#
+ +
+ + 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.

+ + +
+ + +
+
+
+ + + + + + + + + + + + + + +
NameKontakte
+ Bearbeiten · +
+ + + + +
+
Noch keine Organisationen angelegt.
+
+ + +
+

Kontakte von „“

+ +

Noch keine Kunden zugeordnet. Das geht unter Kunden.

+ + + + + + + + +
NameE-Mail
+ +
+ +
+ +
+
+

+
+ + + + + + + +
+ + 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

+
+ + +
Updates verfügbar: $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.
+ + +
+ + + + $p): $m = $manifests[$slug]; $perms = $m['permissions'] ?? []; ?> + + + + + + + + + + +
PluginVersionStatusHerkunft
+ +
+
von
+ +
Berechtigungen () +
  • ()
+
+
Fordert keine Berechtigungen an.
+
plugin.json fehlt oder ist ungültig – das Plugin kann nicht geladen werden.
+
+ Aktiv + = PLUGIN_FAIL_LIMIT): ?>Wegen Fehlern deaktiviert + Inaktiv + + ✓ Signiert + Eigenes Plugin
+ +
+ +
+ +
+ + + +
+ +
+ + + +
+
Noch keine Plugins installiert.
+
+ +
+
+

+ Katalog aktualisieren +
+ +

Es ist noch keine Katalog-Adresse hinterlegt. Trage unten die Adresse der Plugin-Webseite ein, dann könnt ihr Plugins direkt von dort installieren.

+ +
+ +
Stand:
+ + + + '); ?> + + + + + + + + + +
PluginVersionSignatur
+ +
+
von
+
Fordert an: $permLabels[$p] ?? $p, $c['permissions']))) ?>
+
installiert: ' . e($have['version']) . '' : '' ?>✓ Signiert' : 'Signatur vorhanden, Schlüssel fehlt') : 'Nicht signiert' ?> + aktuell + nicht signiert,
nicht installierbar
+ +
+
+ +
Der Katalog enthält keine Plugins.
+ +
+ +
+

Eigenes Plugin hochladen

+

Hier könnt ihr eigene Plugins als ZIP installieren oder aktualisieren, auch ohne Signatur. Diese Plugins sind nicht vom Herausgeber geprüft und laufen mit allen Rechten des Systems, auch mit Zugriff auf die gesamte Datenbank. Installiert nur Code, den ihr selbst geschrieben oder geprüft habt.

+
+ + + + + + + + +
+
+

Das Plugin wird nach der Installation nicht automatisch aktiviert. Vor dem Aktivieren bestätigt ihr die Berechtigungen, die es anfordert.

+
+ + +
+

Katalog und Herausgeber-Schlüssel

+
+ + + + + + +

Den Schlüssel bekommst du von der Plugin-Webseite. Ist er hinterlegt, prüft DeskHub bei jeder Installation, ob das Paket wirklich vom Herausgeber stammt und unverändert ist.

+
+
+
+ + + 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'; +?> + + +
+ + + + $_POST[$k] ?? $default; + $v = fn(string $k, string $col, $default = '') => $f($k, $editPrio[$col] ?? $default); +?> +

+ + — +

+ +
+ + + + + 'Priorität', 'sla' => 'SLA-Zeiten']); ?> + +
+

Prioritätsinformationen

+ + + +
+ + +
+ + +

Administratornotizen – Interne Notizen, sichtbar für alle Administratoren

+ +
+ + + +
+ + Abbrechen +
+
+ + + +
+

Prioritäten & SLA-Zeiten

+ Neue Priorität hinzufügen +
+

Reaktions- und Lösungszeit ab Ticketerstellung bzw. Prioritätsänderung. Mit aktivierten Geschäftszeiten zählen nur die dort eingetragenen Öffnungszeiten.

+ +
+ + + + + + + + + + + + + + + +
PrioritätReaktionLösungTicketsErstelltZuletzt aktualisiert
+ Bearbeiten · +
+ + + + +
+
+
+ + + 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

+ + +
+ + + +
+ '👤 Konto', 'settings' => '⚙ Einstellungen', 'signature' => '✍ Signatur', 'security' => '🔒 Sicherheit'] as $t => $label): ?> + + + + +
+ + + + +
+

Profilbild

+
+
+ + Avatar + +
+ +
+ +
+
+
+ + + +
+ + +
+
+ +
+ + + +
+ +
+
+
+ +
+

Kontodaten

+
+ + + + + + + + +
+ +
+
+
+ +
+

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
+
+ +
+
+
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

+
+ + + query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll(); + ?> + + + +

Neue Tickets werden während Ihrer Abwesenheit automatisch dieser Person zugewiesen.

+ +
Urlaubsmodus ist aktuell aktiv. Sie erhalten keine automatischen Ticket-Zuweisungen.
+ +
+ +
+
+
+ +
+

Übersicht

+
+
+ Rolle + Administrator' : 'Mitarbeiter' ?> +
+
+ Mitglied seit + +
+ prepare('SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ?'); + $ticketCount->execute([$me['id']]); + $myCount = $ticketCount->fetchColumn(); + ?> +
+ Zugewiesene Tickets + +
+
+
+ + + + +
+

E-Mail-Signatur

+

+ Wird automatisch an Ihre ausgehenden Ticket-Antworten angehängt. + Ist keine Mitarbeiter-Signatur gesetzt, greift die Abteilungs-Signatur. +

+
+ + +
+
+ + + + + +
+
+
+ +
+ + +
+
+ +
+

Vorschau:

+
+ +
+
+ +
+ + + + +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:

+
+ + + +
+ +
+
+ +

Schützen Sie Ihr Konto mit einem zeitbasierten Einmalcode (TOTP – kompatibel mit Google Authenticator, Authy, etc.).

+
    +
  1. Installieren Sie Google Authenticator oder Authy
  2. +
  3. Scannen Sie den QR-Code oder geben Sie den Schlüssel manuell ein
  4. +
  5. Tragen Sie den 6-stelligen Code ein und klicken Sie „2FA aktivieren"
  6. +
+
+ QR-Code +
+
Manueller Schlüssel:
+ +
+
+
+ + + + +
+
+ +
+ + +
+ + + + 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'; +?> + +
+

Gespeicherte Ansichten

+ ← Zur Ticket-Liste +
+ + +
+
+

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.

+
    + +
  • + ⠿ +
    +
    + + ⇄ geteilt +
    + +
    + +
    Alle Tickets
    + +
    + +
    + + + +
    + + + + +
    +
    + +
  • + +
+
+ + + + + + + + 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

+ +
+ + +
+

+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ + Abbrechen +
+
+
+ + +
+ +
Noch keine Muster angelegt.
+ + + + + + + + + + + + + + + +
BetreffAbteilungIntervallNächste AusführungStatus

Alle Tage + Bearbeiten +
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +
+ + 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'; +?> + +
+

Bericht-Baukasten

+
+ Standard-Berichte + CSV exportieren +
+
+

Wähle Gruppierung, Kennzahlen, Zeitraum und Filter – nach dem Erstellungsdatum der Tickets. Berichte lassen sich speichern und mit allen teilen.

+ + +
+
+ Gespeichert + + + + +
+
+ +
+ +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ $m): ?> + + +
+
+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + +
+ +
+
Keine Tickets im gewählten Zeitraum.
GesamtØ' : '' ?>
+
+ +
+ Diesen Bericht 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(); + ?> + + + + + + + $r): ?> + + + + + + + + + + + + + +
BezeichnungGeöffnetZugewiesenÜberfälligGeschlossenWieder geöffnetGelöschtServicezeit (Std)Reaktionszeit (Std)
+ 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,

+ +
+ v + 🔄 Updates +
+ +
+ +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
+
Aktuell installiert: v
+
+
+ + Zum Update-Bereich → + +
+ + + +
+
Offene Tickets
+
Mir zugewiesen
+
Nicht zugewiesen
+
Überfällig
+
Unbeantwortet
+ 0): ?> +
🔔 Neue Erwähnungen
+ +
+ + + +
+
+ + +
+ Mein Status: + $sc): + $active = $myAvailStatus === $sk; + ?> + + +
+ + + +
+ + +
+ Team: + +
+ + + +
+ +
+ + +
+
+ + + +

Berichte

+ +
+
Tickets gesamt
+
Gelöst diesen Monat
+
Ø Erstreaktion
+
Ø Lösungszeit
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+

Ticketaktivität (format('d.m.Y')) ?> – format('d.m.Y')) ?>)

+ +
+ + + + + +
+
+ + + 0): ?> +
+
+
+
SLA-Einhaltung im Zeitraum
+
%
+
von Tickets innerhalb der Frist gelöst
+
+
+
+ Im ZeitlimitÜberfällig +
+
+
+
+
+ ✓ + ✗ +
+
+
+
+ + + +
+
+

Nach Status

+ +
+
+

Nach Priorität

+ +
+
+ + + +
+

Statistiken (format('d.m.Y')) ?> – format('d.m.Y')) ?>)

+
+ + + + + +
+ $preset, 'from' => $from->format('Y-m-d'), 'to' => $to->format('Y-m-d')]; + ?> + + + + + +
+ +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

+ +
+ + +
Ihr Passwort wurde geändert. Sie können sich jetzt anmelden.
+

Zur Anmeldung →

+ +
+ + + + + + +
+ +
+
+ +

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'; +?> + +
+ + + $_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); +?> +

+ + — +

+ +
+ + + + + 'Rolle', 'perms' => 'Berechtigungen']); ?> + +
+

Rolleninformationen

+ + + +

Eine deaktivierte Rolle wird ignoriert; betroffene Mitarbeiter haben dann die Standard-Mitarbeiterrechte.

+

Administratornotizen – Interne Notizen, sichtbar für alle Administratoren

+ +
+ + + +
+ + Abbrechen +
+
+ + + +
+

Rollen

+ Neue Rolle hinzufügen +
+

Benutzerdefinierte Berechtigungssets für Mitarbeiter. Ohne Rolle gelten die Standard-Mitarbeiterrechte.

+ +
+ +
+ Standard-Mitarbeiterrechte (ohne eigene Rolle) +
+ $label): ?> +
+ +
+
+
+ +
+ + + + + + + + + + + + + + + +
RollennameStatusZugewiesenErstelltZuletzt aktualisiert
+ Bearbeiten · +
+ + + + +
+
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 +
+ +
+ Handlungsbedarf · Prüfen · Hinweise · in Ordnung +
+ + +
+
·
+
+
+ + + 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'; +?> + +
+

Einstellungen

+ 📥 osTicket Import +
+ +
+ + +
+ '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; + ?> + + + + +
+ + + + +
+ +
+

System

+
+ + + + + + + + + + + + + +
+
+ + Tage +
+ + +
+ + + +
+ +
+ +
+ + Minuten (5–480). Countdown wird oben rechts angezeigt. +
+
+ +
+
+
+ +
+

Anzeigename & URL

+

Wird im Browser-Tab, in E-Mails und in der Web-App angezeigt.

+
+ + + + + + +

Wird in E-Mail-Links verwendet. Kein abschließendes /

+ + + +
+
+
+ +
+ + + +
+ +
+

Firmenlogo

+

PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt.

+ +
+ Logo +

+
+ + +
+ +

Kein Logo hinterlegt.

+ +
+ + +
+
+
+ +
+

Titelbild (Hero-Banner)

+

Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.

+ + Banner +
+ + +
+ +

Kein Titelbild hinterlegt.

+ +
+ + +
+
+
+ +
+ +
+

Startseiten-Inhalte

+

+ Legen Sie fest, welche zusätzlichen Bereiche auf der Kundenportal-Startseite angezeigt werden. +

+
+ + +
+
+
+ +
+

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. +

+ +
+ +
+ + +
+ +
+

+ 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.

+ +
+ + + +
+
+ · Version + + Aktiv + +
Von
+
+
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+
+
+ +
+ +
+ +
+

Themes aus dem Katalog

+

Fertige Designs direkt installieren. Prüfsumme und Signatur werden vor der Installation geprüft.

+ +

Katalog aktualisieren

+ +
+ +
+
+ + Katalog aktualisieren +
+ + + +
+ + + + + + + + + + + + + + + +
ThemeVersionSignatur
+
von
installiert:
✓ Signiert' : 'Nicht signiert' ?> + ✓ Installiert + +
+
Der Katalog enthält keine Themes.
+ + +
+ + + +
+ +
+

SMTP-Versand

+
+ + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+ +
+

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): ?> + ● aktive(s) Postfach/Postfächer eingerichtet + + ○ Noch kein Postfach eingerichtet + + · Letzter Abruf: +

+ 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. +

+
+ + + +

Kanal-Einstellungen → Integrationen → Webhooks → Neuer Webhook

+ + + + +

Bot über @BotFather erstellen, Chat-ID via @userinfobot ermitteln.

+
+ +
+
+
+ + +
+
+ + + +
+

Erlaubte Dateitypen

+

Gefährliche Typen (PHP, EXE, SH etc.) werden immer blockiert – unabhängig von dieser Liste.

+
+ + + +
+ + + +
+ +
+ MB +
+
+
+
+ + + +
+

Datenschutz & Impressum

+

Externer Link oder HTML-Inhalt (öffnet Popup). Ist ein Link gesetzt, wird das HTML-Feld ignoriert.

+
+ +
+
+
🔒 Datenschutzerklärung
+ + + + + + +
+
+
📄 Impressum
+ + + + + + +
+
+
+
+
+ + + +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(); +?> +
+ +
+

Automatische Datenlöschung

+

+ Geschlossene und gelöste Tickets werden nach der gewählten Zeit automatisch anonymisiert. + Name, E-Mail-Adresse und IP werden unkenntlich gemacht — das Ticket und seine Historie bleiben erhalten. +

+
+ + + + + + +
+ + Monate +
+ + + +
+ +
+
+
+ +
+

Status & Vorschau

+
+
+ + +
+

+ Aktuell würden Ticket(s) anonymisiert + (geschlossen/gelöst, älter als 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
+ +
+ + + +
+
+ +
+ + + + +
+

🔄 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. +

+
+
+ + + +
+ + Status: ● aktiv' : '○ deaktiviert' ?> + · Letzte automatische Prüfung: + +
+
+
+
+

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): ?> + + + + + + + +
+
+ + + + +
+
+
+
+

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 + +

+
+
+ + + +
+ +
+ + + +
+ +
+ +

+ ❌ Auf diesem Server nicht möglich:
+ 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 +
+
+
+
+ + + 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'; +?> + +
+ + + $_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'] ?? ''); +?> +

+ + — +

+ +
+ + + + + 'Seite', 'content' => 'Inhalt']); ?> + +
+

Seiteninformationen

+ + + + + + + + + +

Erreichbar unter /page/

+ +
+ + + +
+ + Abbrechen +
+
+ + + +
+

Seiten-Verwaltung

+ Neue Seite hinzufügen +
+

Eigene Inhaltsseiten für das Kundenportal (z. B. Impressum, Datenschutz, Willkommen). Erreichbar unter /page/{slug}.

+ +
+ + + + + + + + + + + + + + + + +
TitelURLNavigationStatusErstelltZuletzt aktualisiert
/page/ + Bearbeiten · +
+ + + + +
+
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'; +?> + +
+ + + $_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); +?> +

+ + — +

+ +
+ + + + + 'SLA-Plan']); ?> +
+

Planinformationen

+ + + +
+
+
+
+
+
+

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

+ +
+ +
+ + Abbrechen +
+
+ + + +
+

SLA-Pläne

+ Neuen SLA-Plan hinzufügen +
+

Benannte Reaktions- und Lösungszeiten, die du Abteilungen und Hilfethemen zuordnest. Ohne Zuordnung gelten die Zeiten der Priorität.

+ +
+ + + + + + + + + + + + + + + + + +
NameStatusReaktionLösungAbteilungenHilfethemenZuletzt aktualisiert
+ Bearbeiten · +
+ + + + +
+
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'; +?> + + +
+ + + + $_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'); +?> +

+ + — +

+ +
+ + + + +
+ + + + +
+ + +
+

Kontoinformationen

+ + + + + +
+ + +
+ + > + + +
+ + + + + + + + + + +
+ + Abbrechen +
+
+ + + + + +
+

Mitarbeiter

+ Neuen Agenten hinzufügen +
+ +
+ + + + + + + + + + + + + + + + +
NameE-MailTypStatusHauptabteilungTeamsErstellt
+ Bearbeiten · +
+ + + + +
+
+
+ + + 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); + }) + ); +});