1290 lines
82 KiB
PHP
1290 lines
82 KiB
PHP
<?php
|
||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||
require_once __DIR__ . '/../includes/crontab_installer.php';
|
||
$staff = require_admin();
|
||
$pdo = get_db();
|
||
|
||
$errors = [];
|
||
$branding = realpath(__DIR__ . '/../assets/branding') ?: (__DIR__ . '/../assets/branding');
|
||
$departments = departments_for_select($pdo, [(int) get_setting('default_department_id', '0'), (int) get_setting('live_chat_department_id', '0')]);
|
||
|
||
// Cronjob-Skripte, die manuell per Button aus dem Backend gestartet werden können.
|
||
// Jeder Job definiert eine run_*()-Funktion in seiner Datei (siehe cron/*.php).
|
||
$cronJobs = [
|
||
'fetch_emails' => ['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'],
|
||
'process_snoozes' => ['label' => 'Wiedervorlagen', 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
|
||
'process_mail_queue' => ['label' => 'Mail-Warteschlange', 'file' => 'process_mail_queue.php', 'fn' => 'run_process_mail_queue'],
|
||
'remind_customers' => ['label' => 'Kundenerinnerung', 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
|
||
'send_csat' => ['label' => 'Zufriedenheitsabfrage', 'file' => 'send_csat.php', 'fn' => 'run_send_csat'],
|
||
'status_checks' => ['label' => 'Statusprüfungen', 'file' => 'status_checks.php', 'fn' => 'run_status_checks'],
|
||
'send_scheduled_replies' => ['label' => 'Geplante Antworten', 'file' => 'send_scheduled_replies.php', 'fn' => 'run_send_scheduled_replies'],
|
||
'send_report_schedules' => ['label' => 'Berichte per E-Mail', 'file' => 'send_report_schedules.php', 'fn' => 'run_send_report_schedules'],
|
||
'run_plugins' => ['label' => 'Plugins (Hintergrundaufgaben)', 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
|
||
];
|
||
|
||
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('minecraft_enabled', !empty($_POST['minecraft_enabled']) ? '1' : '0');
|
||
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('auto_assign_on_open', !empty($_POST['auto_assign_on_open']) ? '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);
|
||
set_setting('sla_notify_mode', ($_POST['sla_notify_mode'] ?? 'admins') === 'assignee' ? 'assignee' : 'admins');
|
||
set_setting('wait_reminder_enabled', !empty($_POST['wait_reminder_enabled']) ? '1' : '0');
|
||
set_setting('wait_reminder_hours', (string) max(1, min(72, (int) ($_POST['wait_reminder_hours'] ?? 4))));
|
||
set_setting('wait_reminder_email', !empty($_POST['wait_reminder_email']) ? '1' : '0');
|
||
set_setting('remind_enabled', !empty($_POST['remind_enabled']) ? '1' : '0');
|
||
set_setting('remind_after_days', (string) max(1, min(60, (int) ($_POST['remind_after_days'] ?? 3))));
|
||
csat_set_enabled(!empty($_POST['csat_enabled']), (int) ($_POST['csat_delay_minutes'] ?? 30));
|
||
$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);
|
||
set_setting('gdpr_tools_enabled', !empty($_POST['gdpr_tools_enabled']) ? '1' : '0');
|
||
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'] ?? '');
|
||
$alertTo = trim((string) ($_POST['error_alert_email'] ?? ''));
|
||
foreach (preg_split('/[,;\s]+/', $alertTo, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $m) {
|
||
if (!filter_var($m, FILTER_VALIDATE_EMAIL)) { $errors[] = 'Die Adresse für Fehler-Mails ist ungültig: ' . $m; break; }
|
||
}
|
||
if (!$name) { $errors[] = 'Bitte einen Anzeigenamen angeben.'; }
|
||
elseif (!$errors) {
|
||
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');
|
||
set_setting('error_alert', !empty($_POST['error_alert']) ? '1' : '0');
|
||
set_setting('error_alert_email', mb_substr($alertTo, 0, 300));
|
||
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.'; }
|
||
elseif (($imgErr = favicon_validate_file($file['tmp_name'], $ext)) !== null) { $errors[] = $imgErr; }
|
||
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 === 'upload_favicon') {
|
||
$file = $_FILES['favicon'] ?? [];
|
||
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
|
||
$err = null;
|
||
if (empty($file['name'])) $err = 'Bitte eine Datei auswählen.';
|
||
elseif (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) $err = 'Upload fehlgeschlagen' . (($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE ? ' (die Datei ist größer als das Upload-Limit des Servers).' : '.');
|
||
elseif ($file['size'] > 1024 * 1024) $err = 'Das Favicon darf höchstens 1 MB groß sein.';
|
||
elseif (!isset(favicon_types()[$ext])) $err = 'Erlaubt: ICO, PNG, JPG, GIF, WebP, SVG.';
|
||
else $err = favicon_validate_file($file['tmp_name'], $ext);
|
||
if ($err !== null) { $errors[] = $err; }
|
||
else {
|
||
foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2);
|
||
if (!move_uploaded_file($file['tmp_name'], $branding . '/favicon.' . $ext)) { $errors[] = 'Speichern fehlgeschlagen (Ordner assets/branding/ nicht beschreibbar?).'; }
|
||
else { set_setting('favicon_path', '/assets/branding/favicon.' . $ext . '?v=' . time()); audit_log($staff['id'], 'Favicon aktualisiert'); flash('success', 'Favicon aktualisiert. Browser zeigen Änderungen oft erst nach Neuladen (Strg+F5) oder in einem neuen Tab.'); redirect('/staff/settings.php?tab=design'); }
|
||
}
|
||
}
|
||
if ($action === 'remove_favicon') {
|
||
foreach (array_keys(favicon_types()) as $e2) @unlink($branding . '/favicon.' . $e2);
|
||
set_setting('favicon_path', null); audit_log($staff['id'], 'Favicon entfernt'); flash('success', 'Favicon entfernt.'); redirect('/staff/settings.php?tab=design');
|
||
}
|
||
if ($action === 'save_logo_options') {
|
||
$hide = !empty($_POST['logo_hide_name']) ? '1' : '0';
|
||
set_setting('logo_hide_name', $hide);
|
||
audit_log($staff['id'], $hide === '1' ? 'Name neben dem Logo ausgeblendet' : 'Name neben dem Logo wird angezeigt');
|
||
flash('success', $hide === '1' ? 'Der Name wird neben dem Logo nicht mehr angezeigt.' : 'Der Name wird wieder neben dem Logo angezeigt.');
|
||
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.'; }
|
||
elseif (($imgErr = favicon_validate_file($file['tmp_name'], $ext)) !== null) { $errors[] = $imgErr; }
|
||
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));
|
||
$mailExt = array_diff(array_filter(array_map(fn($e) => ltrim(trim($e), '.'), explode(',', strtolower($_POST['mail_attachment_extra_extensions'] ?? '')))), $dangerous);
|
||
set_setting('mail_attachment_extra_extensions', implode(', ', $mailExt));
|
||
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');
|
||
}
|
||
// Push-Benachrichtigungen für die Android-App (Firebase Cloud Messaging)
|
||
if ($action === 'save_fcm') {
|
||
set_setting('fcm_enabled', !empty($_POST['fcm_enabled']) ? '1' : '0');
|
||
set_setting('fcm_show_details', !empty($_POST['fcm_show_details']) ? '1' : '0');
|
||
set_setting('push_brand', fcm_clean_brand((string) ($_POST['push_brand'] ?? '')));
|
||
flash('success', 'Einstellungen für die Android-Benachrichtigungen gespeichert.');
|
||
redirect('/staff/settings.php?tab=benachrichtigungen');
|
||
}
|
||
if ($action === 'remove_fcm') {
|
||
set_setting('fcm_service_account', '');
|
||
set_setting('fcm_access_token', '');
|
||
audit_log($staff['id'], 'Firebase-Dienstkonto für Android-Push entfernt');
|
||
flash('success', 'Der Firebase-Schlüssel wurde vom Server entfernt.');
|
||
redirect('/staff/settings.php?tab=benachrichtigungen');
|
||
}
|
||
if ($action === 'test_fcm') {
|
||
if (fcm_mode() === 'none') { flash('error', 'Push ist nicht eingerichtet: Es ist kein Push-Dienst eingetragen und kein eigener Firebase-Schlüssel hinterlegt.'); redirect('/staff/settings.php?tab=benachrichtigungen'); }
|
||
$t = fcm_send_test($pdo, (int) $staff['id']);
|
||
if ($t['devices'] === 0) { flash('error', 'Auf deinem Konto ist noch kein Android-Gerät angemeldet. Melde dich in der App an (Benachrichtigungen erlauben) und versuche es erneut.'); redirect('/staff/settings.php?tab=benachrichtigungen'); }
|
||
$t['sent'] > 0 ? flash('success', "Testnachricht an {$t['sent']} Gerät(e) gesendet. Sie sollte jetzt auf dem Handy erscheinen.") : flash('error', 'Senden fehlgeschlagen: ' . ($t['error'] !== '' ? $t['error'] : 'unbekannter Fehler'));
|
||
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, null, false);
|
||
$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']));
|
||
$mailExtraExtensions = get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT);
|
||
$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'));
|
||
$cronSnoozes = cron_last_run_label(get_setting('cron_process_snoozes_last_run'));
|
||
$cronRemind = cron_last_run_label(get_setting('cron_remind_customers_last_run'));
|
||
$cronPlugins = cron_last_run_label(get_setting('cron_run_plugins_last_run'));
|
||
$cronCsat = cron_last_run_label(get_setting('cron_send_csat_last_run'));
|
||
|
||
|
||
$tab = $_GET['tab'] ?? 'allgemein';
|
||
$pageTitle = 'Einstellungen';
|
||
$activePage = 'settings';
|
||
require __DIR__ . '/_header.php';
|
||
?>
|
||
|
||
<div class="topbar">
|
||
<h1>Einstellungen</h1>
|
||
<a href="/staff/import_osticket.php" class="btn secondary">📥 osTicket Import</a>
|
||
</div>
|
||
|
||
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
|
||
|
||
<!-- Tab-Navigation -->
|
||
<div style="display:flex; border-bottom:2px solid var(--border); margin-bottom:24px; gap:0">
|
||
<?php
|
||
$tabs = [
|
||
'allgemein' => '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;
|
||
?>
|
||
<a href="?tab=<?= $key ?>"
|
||
style="padding:11px 20px; font-size:.88rem; font-weight:<?= $active?'600':'400' ?>; color:<?= $active?'var(--primary)':'var(--muted)' ?>; text-decoration:none; border-bottom:2px solid <?= $active?'var(--primary)':'transparent' ?>; margin-bottom:-2px; transition:color .15s, border-color .15s; white-space:nowrap">
|
||
<?= $label ?>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<!-- ── TAB: ALLGEMEIN ─────────────────────────────────────────────────────── -->
|
||
<?php if ($tab === 'allgemein'): ?>
|
||
<?php $sessionTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30); ?>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px">
|
||
|
||
<div class="card">
|
||
<h3>System</h3>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_system">
|
||
<label>Helpdesk-Status</label>
|
||
<select name="helpdesk_status">
|
||
<option value="online" <?= $helpdeskStatus==='online' ?'selected':'' ?>>Online</option>
|
||
<option value="offline" <?= $helpdeskStatus==='offline'?'selected':'' ?>>Offline (Wartungsmodus)</option>
|
||
</select>
|
||
<label style="margin-top:12px">Offline-Nachricht</label>
|
||
<textarea name="offline_message" rows="3"><?= e($offlineMessage) ?></textarea>
|
||
<label style="margin-top:12px">Standardabteilung</label>
|
||
<select name="default_department_id">
|
||
<?php foreach ($departments as $d): ?>
|
||
<option value="<?= $d['id'] ?>" <?= $defaultDeptId==$d['id']?'selected':'' ?>><?= e($d['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:14px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="kb_enabled" value="1" style="width:auto" <?= $kbEnabled?'checked':'' ?>>
|
||
Wissensdatenbank im Kundenportal anzeigen
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="minecraft_enabled" value="1" style="width:auto" <?= get_setting('minecraft_enabled','0')==='1'?'checked':'' ?>>
|
||
Minecraft-Plugin auf der Apps-Seite anzeigen (Download und Einrichtung, Ausfall-Tickets für Minecraft-Server)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="ticket_ratings_enabled" value="1" style="width:auto" <?= get_setting('ticket_ratings_enabled','1')==='1'?'checked':'' ?>>
|
||
Ticket-Bewertung aktivieren (Kunden können nach Schließung 1–5 Sterne vergeben)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="auto_time_tracking_enabled" value="1" style="width:auto" <?= get_setting('auto_time_tracking_enabled','1')==='1'?'checked':'' ?>>
|
||
Automatische Zeiterfassung (misst automatisch, wie lange ein Mitarbeiter ein Ticket geöffnet hat)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="auto_assign_on_open" value="1" style="width:auto" <?= get_setting('auto_assign_on_open','1')==='1'?'checked':'' ?>>
|
||
Beim Öffnen automatisch zuweisen (ein noch nicht zugewiesenes Ticket geht an den Mitarbeiter, der es öffnet)
|
||
</label>
|
||
<?php $autoCloseEnabled = get_setting('auto_close_enabled','0')==='1'; ?>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="auto_close_enabled" value="1" style="width:auto" id="auto_close_enabled" onchange="document.getElementById('auto-close-days-row').style.display=this.checked?'block':'none'" <?= $autoCloseEnabled?'checked':'' ?>>
|
||
Tickets automatisch schließen, wenn der Kunde nach einer Antwort nicht mehr reagiert
|
||
</label>
|
||
<div id="auto-close-days-row" style="display:<?= $autoCloseEnabled?'block':'none' ?>;margin-top:8px;margin-left:26px">
|
||
<label style="font-size:.82rem">Nach wie vielen Tagen ohne Kundenreaktion</label><br>
|
||
<input type="number" name="auto_close_days" value="<?= (int) get_setting('auto_close_days','7') ?>" min="1" max="90" style="width:90px">
|
||
<span class="muted" style="font-size:.82rem">Tage</span>
|
||
</div>
|
||
<label for="sla_notify_mode" style="margin-top:12px">Bei überschrittener SLA-Frist benachrichtigen</label>
|
||
<select id="sla_notify_mode" name="sla_notify_mode" style="max-width:420px">
|
||
<option value="admins" <?= sla_notify_mode() === 'admins' ? 'selected' : '' ?>>Nur die Administratoren (per E-Mail)</option>
|
||
<option value="assignee" <?= sla_notify_mode() === 'assignee' ? 'selected' : '' ?>>Bearbeiter bzw. Abteilung (E-Mail und Push in der App)</option>
|
||
</select>
|
||
<?php $waitOn = wait_reminder_enabled(); ?>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="wait_reminder_enabled" value="1" style="width:auto" id="wait_reminder_enabled" onchange="document.getElementById('wait-reminder-row').style.display=this.checked?'block':'none'" <?= $waitOn ? 'checked' : '' ?>>
|
||
Bearbeiter erinnern, wenn ein Ticket zu lange auf ihre Antwort wartet (Hinweis vor Ablauf der SLA-Frist)
|
||
</label>
|
||
<div id="wait-reminder-row" style="display:<?= $waitOn ? 'block' : 'none' ?>;margin-top:8px;margin-left:26px">
|
||
<label style="font-size:.82rem">Nach wie vielen Arbeitsstunden ohne Antwort</label><br>
|
||
<input type="number" name="wait_reminder_hours" value="<?= wait_reminder_hours() ?>" min="1" max="72" style="width:90px">
|
||
<span class="muted" style="font-size:.82rem">Stunden (zählt nur die Geschäftszeiten, wenn sie in den SLA-Einstellungen eingerichtet sind)</span>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:6px;font-weight:400;cursor:pointer"><input type="checkbox" name="wait_reminder_email" value="1" style="width:auto" <?= wait_reminder_email() ? 'checked' : '' ?>> Zusätzlich per E-Mail (sonst nur Push in der App und im Browser)</label>
|
||
</div>
|
||
<?php $remindEnabled = customer_reminder_enabled(); ?>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="remind_enabled" value="1" style="width:auto" id="remind_enabled" onchange="document.getElementById('remind-days-row').style.display=this.checked?'block':'none'" <?= $remindEnabled?'checked':'' ?>>
|
||
Kunden erinnern, wenn sie auf eine Antwort nicht reagieren (einmalig, vor dem automatischen Schließen)
|
||
</label>
|
||
<div id="remind-days-row" style="display:<?= $remindEnabled?'block':'none' ?>;margin-top:8px;margin-left:26px">
|
||
<label style="font-size:.82rem">Nach wie vielen Tagen ohne Reaktion</label><br>
|
||
<input type="number" name="remind_after_days" value="<?= customer_reminder_days() ?>" min="1" max="60" style="width:90px">
|
||
<span class="muted" style="font-size:.82rem">Tage (am besten kürzer als die Frist zum automatischen Schließen)</span>
|
||
</div>
|
||
<?php $csatOn = csat_enabled(); ?>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="csat_enabled" value="1" style="width:auto" id="csat_enabled" onchange="document.getElementById('csat-delay-row').style.display=this.checked?'block':'none'" <?= $csatOn?'checked':'' ?>>
|
||
Zufriedenheitsabfrage: Nach dem Schließen oder Lösen eines Tickets den Kunden um eine Bewertung (1 bis 5 Sterne) bitten
|
||
</label>
|
||
<div id="csat-delay-row" style="display:<?= $csatOn?'block':'none' ?>;margin-top:8px;margin-left:26px">
|
||
<label style="font-size:.82rem">Mail senden nach</label><br>
|
||
<input type="number" name="csat_delay_minutes" value="<?= csat_delay_minutes() ?>" min="0" max="1440" style="width:90px">
|
||
<span class="muted" style="font-size:.82rem">Minuten (so bleibt Zeit, ein versehentlich geschlossenes Ticket wieder zu öffnen). Gilt nur für Tickets, die ab dem Einschalten geschlossen werden. Die Auswertung steht unter Übersicht.</span>
|
||
</div>
|
||
<?php $liveChatEnabled = get_setting('live_chat_enabled','0')==='1'; ?>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="live_chat_enabled" value="1" style="width:auto" id="live_chat_enabled" onchange="document.getElementById('live-chat-dept-row').style.display=this.checked?'block':'none'" <?= $liveChatEnabled?'checked':'' ?>>
|
||
Live-Chat im Kundenportal (Chat-Widget unten rechts, auch ohne Kundenkonto nutzbar)
|
||
</label>
|
||
<div id="live-chat-dept-row" style="display:<?= $liveChatEnabled?'block':'none' ?>;margin-top:8px;margin-left:26px">
|
||
<label style="font-size:.82rem">Abteilung für Live-Chat-Tickets</label>
|
||
<select name="live_chat_department_id" style="max-width:280px">
|
||
<option value="">Standardabteilung verwenden</option>
|
||
<?php foreach ($departments as $d): ?>
|
||
<option value="<?= $d['id'] ?>" <?= get_setting('live_chat_department_id')==$d['id']?'selected':'' ?>><?= e($d['name']) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:10px;font-weight:400;cursor:pointer;font-size:.85rem">
|
||
<input type="checkbox" name="live_chat_send_transcript" value="1" style="width:auto" <?= get_setting('live_chat_send_transcript','1')==='1'?'checked':'' ?>>
|
||
Chatverlauf per E-Mail an den Kunden senden, sobald ein Mitarbeiter den Chat beendet
|
||
</label>
|
||
</div>
|
||
|
||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border-soft)">
|
||
<label for="session_timeout" style="font-weight:500;display:block;margin-bottom:6px">Automatischer Logout nach Inaktivität</label>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<input type="number" id="session_timeout" name="staff_session_timeout" value="<?= $sessionTimeout ?>" min="5" max="480" style="width:90px">
|
||
<span class="muted" style="font-size:.85rem">Minuten (5–480). Countdown wird oben rechts angezeigt.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Anzeigename & URL</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:14px">Wird im Browser-Tab, in E-Mails und in der Web-App angezeigt.</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_name">
|
||
<label>Name</label>
|
||
<input type="text" name="display_name" value="<?= e($currentName) ?>" required>
|
||
<label style="margin-top:12px">Website-URL</label>
|
||
<input type="text" name="site_url" value="<?= e(get_setting('site_url', config()['site']['url'] ?? '')) ?>" placeholder="https://support.beispiel.de">
|
||
<p class="muted" style="font-size:.78rem;margin-top:3px">Wird in E-Mail-Links verwendet. Kein abschließendes /</p>
|
||
<label style="margin-top:12px">Zeitzone</label>
|
||
<select name="timezone">
|
||
<?php foreach (['Europe/Berlin','Europe/Vienna','Europe/Zurich','Europe/London','UTC','America/New_York','America/Los_Angeles','Asia/Tokyo'] as $tz): ?>
|
||
<option value="<?= $tz ?>" <?= get_setting('timezone', config()['site']['timezone'] ?? 'Europe/Berlin')===$tz?'selected':'' ?>><?= $tz ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="mail_enabled" value="1" style="width:auto" <?= get_setting('mail_enabled','0')==='1'?'checked':'' ?>>
|
||
E-Mail-Versand aktiv (PHP mail() Fallback)
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="error_alert" value="1" style="width:auto" <?= get_setting('error_alert','1')==='1'?'checked':'' ?>>
|
||
Bei schweren Fehlern auf der Webseite eine E-Mail senden
|
||
</label>
|
||
<input type="text" name="error_alert_email" value="<?= e(get_setting('error_alert_email', '')) ?>" placeholder="Empfänger (leer = Administratoren), mehrere mit Komma">
|
||
<p class="muted" style="font-size:.78rem;margin-top:3px">Gemeldet werden Fehler, bei denen eine Seite nicht angezeigt werden kann. Dieselbe Meldung kommt höchstens einmal pro Stunde, insgesamt höchstens 10 Mails pro Stunde. Voraussetzung ist ein funktionierender E-Mail-Versand und eine erreichbare Datenbank.</p>
|
||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── TAB: DESIGN ────────────────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'design'): ?>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px">
|
||
|
||
<div class="card">
|
||
<h3>Firmenlogo</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">PNG/SVG empfohlen, max. 2 MB. Wird überall statt „DeskHub" angezeigt. Der Name der Installation steht standardmäßig daneben und lässt sich unten ausblenden.</p>
|
||
<?php if ($currentLogo): ?>
|
||
<div style="background:#16202a;padding:14px;border-radius:8px;margin-bottom:14px;display:inline-block">
|
||
<img src="<?= e($currentLogo) ?>" alt="Logo" style="max-height:48px;max-width:220px">
|
||
</div><br>
|
||
<form method="post" style="display:inline" onsubmit="return confirm('Logo entfernen?')">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="remove_logo">
|
||
<button type="submit" class="btn secondary">Entfernen</button>
|
||
</form>
|
||
<form method="post" style="margin-top:14px">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_logo_options">
|
||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="logo_hide_name" value="1" style="width:auto" <?= get_setting('logo_hide_name', '0') === '1' ? 'checked' : '' ?>>
|
||
Name „<?= e(get_setting('display_name', 'DeskHub')) ?>“ neben dem Logo ausblenden
|
||
</label>
|
||
<div class="btn-row"><button type="submit" class="btn secondary">Speichern</button></div>
|
||
</form>
|
||
<?php else: ?>
|
||
<p class="muted">Kein Logo hinterlegt.</p>
|
||
<?php endif; ?>
|
||
<form method="post" enctype="multipart/form-data" style="margin-top:16px">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="upload_logo">
|
||
<input type="file" name="logo" accept=".png,.jpg,.jpeg,.gif,.svg">
|
||
<div class="btn-row"><button type="submit" class="btn">Hochladen</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Favicon (Symbol im Browser-Tab)</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">ICO, PNG, JPG, GIF, WebP oder SVG, höchstens 1 MB, am besten quadratisch (z. B. 256×256). Ohne Favicon verwendet DeskHub das Logo.</p>
|
||
<?php $currentFavicon = get_setting('favicon_path'); ?>
|
||
<?php if ($currentFavicon): ?>
|
||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px">
|
||
<span style="background:repeating-conic-gradient(#e5e7eb 0% 25%, #fff 0% 50%) 50% / 12px 12px;border:1px solid var(--border);border-radius:8px;padding:8px;display:inline-flex">
|
||
<img src="<?= e($currentFavicon) ?>" alt="Favicon" style="width:32px;height:32px;object-fit:contain">
|
||
</span>
|
||
<form method="post" style="display:inline" onsubmit="return confirm('Favicon entfernen?')">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="remove_favicon">
|
||
<button type="submit" class="btn secondary">Entfernen</button>
|
||
</form>
|
||
</div>
|
||
<?php else: ?>
|
||
<p class="muted">Kein eigenes Favicon hinterlegt.</p>
|
||
<?php endif; ?>
|
||
<form method="post" enctype="multipart/form-data" style="margin-top:16px">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="upload_favicon">
|
||
<input type="file" name="favicon" accept=".ico,.png,.jpg,.jpeg,.gif,.webp,.svg">
|
||
<div class="btn-row"><button type="submit" class="btn">Hochladen</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Titelbild (Hero-Banner)</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">Hintergrundbild im dunklen Hero-Bereich. Mind. 1600×500 px, max. 5 MB.</p>
|
||
<?php if ($currentBanner): ?>
|
||
<img src="<?= e($currentBanner) ?>" alt="Banner" style="width:100%;max-height:120px;object-fit:cover;border-radius:6px;margin-bottom:12px">
|
||
<form method="post" onsubmit="return confirm('Titelbild entfernen?')">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="remove_banner">
|
||
<button type="submit" class="btn secondary">Entfernen</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<p class="muted">Kein Titelbild hinterlegt.</p>
|
||
<?php endif; ?>
|
||
<form method="post" enctype="multipart/form-data" style="margin-top:16px">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="upload_banner">
|
||
<input type="file" name="banner" accept=".png,.jpg,.jpeg,.gif">
|
||
<div class="btn-row"><button type="submit" class="btn">Hochladen</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:20px">
|
||
<h3>Startseiten-Inhalte</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">
|
||
Legen Sie fest, welche zusätzlichen Bereiche auf der Kundenportal-Startseite angezeigt werden.
|
||
</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_homepage_content">
|
||
<label style="display:flex;align-items:center;gap:9px;font-weight:400;cursor:pointer">
|
||
<input type="checkbox" name="homepage_popular_articles_enabled" value="1" style="width:auto" <?= $homepagePopularArticlesEnabled ? 'checked' : '' ?>>
|
||
Bereich „Beliebte Artikel“ auf der Startseite anzeigen
|
||
</label>
|
||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:20px">
|
||
<h3>Eigene Kundenportal-Themes</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">
|
||
Laden Sie mehrere Designs als ZIP hoch und aktivieren Sie eines davon global für alle Kunden.
|
||
Das ZIP muss im Stammverzeichnis eine <code>theme.css</code> enthalten. Zulässig sind CSS,
|
||
PNG, JPG, GIF, WebP, WOFF und WOFF2; maximal 10 MB.
|
||
Autor und Version können im CSS-Kopf mit <code>Author:</code> und <code>Version:</code> angegeben werden.
|
||
</p>
|
||
|
||
<form method="post" enctype="multipart/form-data" style="display:flex;align-items:end;gap:12px;margin-bottom:22px;flex-wrap:wrap">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="upload_custom_theme">
|
||
<div>
|
||
<label for="theme_zip">Theme-Paket</label>
|
||
<input id="theme_zip" type="file" name="theme_zip" accept=".zip,application/zip" required>
|
||
</div>
|
||
<button type="submit" class="btn">Theme hochladen</button>
|
||
</form>
|
||
<p class="muted" style="font-size:.78rem;margin-top:-12px;margin-bottom:18px">
|
||
Der Anzeigename wird automatisch aus dem ZIP-Dateinamen übernommen, zum Beispiel
|
||
<code>Classic-Support-Blue.zip</code> → <strong>Classic Support Blue</strong>.
|
||
Ein erneuter Upload mit demselben Namen ersetzt das vorhandene Theme.
|
||
</p>
|
||
|
||
<?php if (!$customThemes): ?>
|
||
<p class="muted">Noch keine eigenen Themes hochgeladen.</p>
|
||
<?php else: ?>
|
||
<div style="display:grid;gap:10px">
|
||
<?php $__catThemes = theme_catalog_url() !== '' ? (theme_catalog_cached()['themes'] ?? []) : []; ?>
|
||
<?php foreach ($customThemes as $theme): ?>
|
||
<?php $isActiveTheme = $activeCustomTheme === $theme['id']; ?>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;padding:13px 14px;border:1px solid <?= $isActiveTheme ? 'var(--primary)' : 'var(--border)' ?>;border-radius:8px">
|
||
<div style="display:flex;align-items:center;gap:14px;min-width:0"><?= installed_theme_thumb($theme, $__catThemes) ?><div style="min-width:0">
|
||
<strong><?= e($theme['name']) ?> <span class="muted" style="font-weight:500">· Version <?= e($theme['theme_version']) ?></span></strong>
|
||
<?php if ($isActiveTheme): ?>
|
||
<span style="display:inline-block;margin-left:8px;padding:2px 7px;border-radius:999px;background:var(--primary-lt);color:var(--primary);font-size:.75rem;font-weight:700">Aktiv</span>
|
||
<?php endif; ?>
|
||
<div class="muted" style="font-size:.78rem">Von <?= e($theme['author']) ?></div>
|
||
</div></div>
|
||
<div class="btn-row" style="margin:0;flex-wrap:nowrap">
|
||
<?php if ($isActiveTheme): ?>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="deactivate_custom_theme">
|
||
<button type="submit" class="btn secondary">Standarddesign nutzen</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="activate_custom_theme">
|
||
<input type="hidden" name="theme_id" value="<?= e($theme['id']) ?>">
|
||
<button type="submit" class="btn">Aktivieren</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<form method="post" onsubmit="return confirm('Theme wirklich löschen?')">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="delete_custom_theme">
|
||
<input type="hidden" name="theme_id" value="<?= e($theme['id']) ?>">
|
||
<button type="submit" class="btn secondary">Löschen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:20px">
|
||
<h3>Themes aus dem Katalog</h3>
|
||
<p class="muted" style="font-size:.85rem">Fertige Designs direkt installieren. Prüfsumme und Signatur werden vor der Installation geprüft.</p>
|
||
<?php if (plugin_strict_mode()): ?>
|
||
<?php if (theme_catalog_url() !== ''): ?><p><a class="btn secondary" href="/staff/settings.php?tab=design&theme_refresh=1">Katalog aktualisieren</a></p><?php endif; ?>
|
||
<?php else: ?>
|
||
<form method="post" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_theme_catalog">
|
||
<div style="flex:1;min-width:260px"><label for="theme_catalog_url">Katalog-Adresse (https)</label>
|
||
<input id="theme_catalog_url" type="text" name="theme_catalog_url" value="<?= e(theme_catalog_url()) ?>" placeholder="https://plugins.example.de/catalog.php?type=themes"></div>
|
||
<button type="submit" class="btn secondary">Speichern</button>
|
||
<?php if (theme_catalog_url() !== ''): ?><a class="btn secondary" href="/staff/settings.php?tab=design&theme_refresh=1">Katalog aktualisieren</a><?php endif; ?>
|
||
</form>
|
||
<?php endif; ?>
|
||
<?php if (theme_catalog_url() !== ''): $themeCatalog = theme_catalog_cached(isset($_GET['theme_refresh']), 60); ?>
|
||
<?php if (!$themeCatalog['ok']): ?>
|
||
<div class="flash error" style="margin-top:12px"><?= e($themeCatalog['error']) ?></div>
|
||
<?php else: ?>
|
||
<table style="margin-top:12px">
|
||
<thead><tr><th>Theme</th><th>Version</th><th>Signatur</th><th></th></tr></thead>
|
||
<tbody>
|
||
<?php foreach ($themeCatalog['themes'] as $t): ?>
|
||
<tr>
|
||
<td><div style="display:flex;gap:12px;align-items:flex-start"><?= catalog_thumb('theme', $t) ?><div style="min-width:0"><strong><?= e($t['name']) ?></strong><div class="muted" style="font-size:.82rem"><?= e($t['description']) ?></div>
|
||
<?php if ($t['author'] !== ''): ?><div class="muted" style="font-size:.78rem">von <?= e($t['author']) ?></div><?php endif; ?></div></div></td>
|
||
<td><?= e($t['version']) ?><?php $__s2 = theme_catalog_state($t); if ($__s2['installed'] && $__s2['version'] !== '0'): ?><div class="muted" style="font-size:.76rem">installiert: <?= e($__s2['version']) ?></div><?php endif; ?></td>
|
||
<td style="font-size:.82rem"><?= $t['signature'] !== '' ? '<span style="color:#15803d;font-weight:600">✓ Signiert</span>' : '<span style="color:#b45309">Nicht signiert</span>' ?></td>
|
||
<?php $__state = theme_catalog_state($t); ?>
|
||
<td><?php if ($__state['installed'] && !$__state['update']): ?>
|
||
<span style="color:#15803d;font-weight:600;font-size:.85rem">✓ Installiert</span>
|
||
<?php else: ?>
|
||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="theme_catalog_install"><input type="hidden" name="slug" value="<?= e($t['slug']) ?>"><button type="submit" class="btn"><?= $__state['update'] ? 'Aktualisieren' : 'Installieren' ?></button></form>
|
||
<?php endif; ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
<?php if (!$themeCatalog['themes']): ?><tr><td colspan="4" class="muted">Der Katalog enthält keine Themes.</td></tr><?php endif; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<!-- ── TAB: E-MAIL ────────────────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'email'): ?>
|
||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:20px">
|
||
|
||
<div class="card">
|
||
<h3>SMTP-Versand</h3>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_smtp">
|
||
<label>SMTP-Server</label>
|
||
<input type="text" name="smtp_host" value="<?= e($smtpHost) ?>" placeholder="smtp.beispiel.de">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||
<div>
|
||
<label>Port</label>
|
||
<input type="number" name="smtp_port" value="<?= e($smtpPort) ?>" placeholder="587">
|
||
</div>
|
||
<div>
|
||
<label>Verschlüsselung</label>
|
||
<select name="smtp_encryption" onchange="var p=this.form.elements.smtp_port;if(p&&['','25','465','587'].indexOf(String(p.value).trim())!==-1)p.value={tls:'587',ssl:'465',none:'25'}[this.value]">
|
||
<option value="tls" <?= $smtpEncryption==='tls'?'selected':'' ?>>STARTTLS (587)</option>
|
||
<option value="ssl" <?= $smtpEncryption==='ssl'?'selected':'' ?>>SSL/TLS (465)</option>
|
||
<option value="none" <?= $smtpEncryption==='none'?'selected':'' ?>>Keine</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<label style="margin-top:10px">Benutzername</label>
|
||
<input type="text" name="smtp_username" value="<?= e($smtpUsername) ?>" autocomplete="off">
|
||
<label style="margin-top:10px">Passwort</label>
|
||
<input type="password" name="smtp_password" placeholder="<?= get_setting('smtp_password') ? '(unverändert)' : '' ?>" autocomplete="new-password">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px">
|
||
<div>
|
||
<label>Absendername</label>
|
||
<input type="text" name="smtp_from_name" value="<?= e($smtpFromName) ?>" placeholder="Support">
|
||
</div>
|
||
<div>
|
||
<label>Absender-E-Mail</label>
|
||
<input type="email" name="smtp_from_email" value="<?= e($smtpFromEmail) ?>" placeholder="support@firma.de">
|
||
</div>
|
||
</div>
|
||
<div class="btn-row" style="gap:8px">
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
<form method="post" style="margin-top:8px">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="test_smtp">
|
||
<button type="submit" class="btn secondary">Test-Mail senden</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>E-Mail-Abruf (IMAP)</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">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.</p>
|
||
<?php $imapAccountCount = (int) $pdo->query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn(); ?>
|
||
<p style="margin-bottom:16px">
|
||
<?php if ($imapAccountCount > 0): ?>
|
||
<strong style="color:var(--success)">● <?= $imapAccountCount ?> aktive(s) Postfach/Postfächer eingerichtet</strong>
|
||
<?php else: ?>
|
||
<strong class="muted">○ Noch kein Postfach eingerichtet</strong>
|
||
<?php endif; ?>
|
||
· Letzter Abruf: <?= e($cronFetch) ?>
|
||
</p>
|
||
<a href="/staff/imap_accounts.php" class="btn">Postfächer verwalten →</a>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── TAB: BENACHRICHTIGUNGEN ────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'benachrichtigungen'): ?>
|
||
<div style="display:flex;flex-wrap:wrap;gap:20px;align-items:flex-start">
|
||
<div class="card" style="max-width:640px;flex:1 1 420px;min-width:0">
|
||
<h3>Discord & Telegram</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">Mitarbeiter werden bei neuen Tickets und Antworten benachrichtigt. Ticket-Daten werden dabei an den jeweiligen Drittanbieter übermittelt.</p>
|
||
<p class="muted" style="font-size:.8rem;margin-bottom:12px;padding:8px 10px;background:var(--primary-lt);border-radius:6px;border:1px solid var(--border)">
|
||
🔒 Webhook-URL und Bot-Token werden verschlüsselt gespeichert. Leer lassen = unverändert. Wert <code>-</code> löscht ihn.
|
||
</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_notifications">
|
||
<label>Discord Webhook-URL</label>
|
||
<input type="password" name="discord_webhook_url" autocomplete="new-password" placeholder="<?= get_setting('discord_webhook_url') ? '(gesetzt – leer lassen = unverändert)' : 'https://discord.com/api/webhooks/...' ?>">
|
||
<p class="muted" style="font-size:.78rem;margin-top:4px">Kanal-Einstellungen → Integrationen → Webhooks → Neuer Webhook</p>
|
||
<label style="margin-top:14px">Telegram Bot-Token</label>
|
||
<input type="password" name="telegram_bot_token" autocomplete="new-password" placeholder="<?= get_setting('telegram_bot_token') ? '(gesetzt – leer lassen = unverändert)' : '123456789:AAExampleToken' ?>">
|
||
<label style="margin-top:10px">Telegram Chat-ID</label>
|
||
<input type="text" name="telegram_chat_id" value="<?= e(get_setting('telegram_chat_id', '')) ?>" placeholder="-1001234567890">
|
||
<p class="muted" style="font-size:.78rem;margin-top:4px">Bot über @BotFather erstellen, Chat-ID via @userinfobot ermitteln.</p>
|
||
<div class="btn-row" style="gap:8px">
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
<form method="post" style="margin-top:8px">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="test_notifications">
|
||
<button type="submit" class="btn secondary">Testnachricht senden</button>
|
||
</form>
|
||
</div>
|
||
|
||
<?php
|
||
$__fcmMode = fcm_mode();
|
||
$__fcmSa = fcm_service_account();
|
||
$__fcmDevices = (int) $pdo->query('SELECT COUNT(*) FROM push_devices')->fetchColumn();
|
||
$__fcmErr = (string) get_setting('fcm_last_error', '');
|
||
$__fcmOk = (string) get_setting('fcm_last_ok', '');
|
||
$__fcmFromConfig = (string) (config()['fcm']['service_account_file'] ?? '') !== '';
|
||
?>
|
||
<div class="card" style="max-width:640px;flex:1 1 420px;min-width:0">
|
||
<h3>📱 Android-App: Push-Benachrichtigungen</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:12px">Popups auf dem Handy bei neuen Tickets, Kundenantworten und Zuweisungen, auch wenn die App geschlossen ist. Die Nachrichten laufen über Google (Firebase Cloud Messaging).</p>
|
||
<p style="margin:0 0 10px;font-size:.9rem">
|
||
<?php if ($__fcmMode === 'own'): ?>
|
||
<strong style="color:#15803d">✓ Eigenes Firebase-Projekt</strong> · <code><?= e($__fcmSa['project_id']) ?></code><?= $__fcmFromConfig ? ' (aus config/config.php)' : '' ?><br>
|
||
<?php elseif ($__fcmMode === 'relay'): ?>
|
||
<strong style="color:#15803d">✓ Push ist aktiv</strong> <span class="muted">· über den DeskHub-Push-Dienst, es ist nichts einzurichten</span><br>
|
||
<?php else: ?>
|
||
<strong style="color:#b45309">Nicht verfügbar.</strong> <span class="muted">In dieser Version ist kein Push-Dienst eingetragen. Ihr könnt unten ein eigenes Firebase-Projekt verwenden.</span><br>
|
||
<?php endif; ?>
|
||
<?php if ($__fcmMode !== 'none'): ?><span class="muted"><?= (int) $__fcmDevices ?> Gerät(e) angemeldet<?= $__fcmOk !== '' ? ' · letzter erfolgreicher Versand: ' . e($__fcmOk) : '' ?></span><?php endif; ?>
|
||
<?php if ($__fcmErr !== ''): ?><br><span style="color:#b91c1c;font-size:.85rem">Letzter Fehler: <?= e($__fcmErr) ?></span><?php endif; ?>
|
||
</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_fcm">
|
||
<label style="display:flex;align-items:center;gap:8px;margin-top:0"><input type="checkbox" name="fcm_enabled" value="1" style="width:auto" <?= get_setting('fcm_enabled', '1') === '1' ? 'checked' : '' ?>> Push-Benachrichtigungen senden</label>
|
||
<label style="display:flex;align-items:flex-start;gap:8px;margin-top:10px"><input type="checkbox" name="fcm_show_details" value="1" style="width:auto;margin-top:3px" <?= get_setting('fcm_show_details', '0') === '1' ? 'checked' : '' ?>>
|
||
<span>Betreff und Kundenname in der Benachrichtigung anzeigen<br><span class="muted" style="font-size:.8rem">Aus (Standard): Es steht nur „Neues Ticket #T-123“ drin, Betreff und Kundenname verlassen euren Server nicht. An: Betreff und Name laufen dann über den Push-Dienst und die Server von Google.</span></span></label>
|
||
<label for="push_brand" style="margin-top:14px">Name in der Benachrichtigung (optional)</label>
|
||
<input type="text" id="push_brand" name="push_brand" maxlength="40" value="<?= e(fcm_brand()) ?>" placeholder="z. B. Windelgeschichten Support">
|
||
<p class="muted" style="font-size:.78rem;margin-top:4px">Erscheint in jedem Popup neben dem App-Namen („DeskHub“). Den App-Namen selbst kann Android pro Server nicht ändern.</p>
|
||
<div class="btn-row" style="gap:8px;margin-top:12px"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="test_fcm"><button type="submit" class="btn secondary" <?= $__fcmMode !== 'none' ? '' : 'disabled' ?>>Testnachricht an meine Geräte</button></form>
|
||
<?php if ($__fcmSa && !$__fcmFromConfig): ?><form method="post" onsubmit="return confirm('Den eigenen Firebase-Schlüssel vom Server entfernen? Danach wird wieder der DeskHub-Push-Dienst verwendet.')"><?= csrf_field() ?><input type="hidden" name="action" value="remove_fcm"><button type="submit" class="btn secondary" style="border-color:var(--danger);color:var(--danger)">Eigenen Schlüssel entfernen</button></form><?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- ── TAB: UPLOADS ───────────────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'uploads'): ?>
|
||
<div class="card" style="max-width:640px">
|
||
<h3>Erlaubte Dateitypen</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:16px">Gefährliche Typen (PHP, EXE, SH etc.) werden immer blockiert – unabhängig von dieser Liste.</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_uploads">
|
||
<label>Erlaubte Datei-Endungen <span class="muted">(kommagetrennt)</span></label>
|
||
<input type="text" name="upload_allowed_extensions" value="<?= e($uploadExtensions) ?>" placeholder="png, jpg, pdf, docx, xlsx, zip">
|
||
<label style="margin-top:14px;display:block">Zusätzlich erlaubt für Anhänge eingehender E-Mails <span class="muted">(kommagetrennt)</span></label>
|
||
<input type="text" name="mail_attachment_extra_extensions" value="<?= e($mailExtraExtensions) ?>" placeholder="doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg">
|
||
<div class="muted" style="font-size:.78rem;margin-top:4px">Kunden schicken oft Büro-Dateien per Mail, die im Formular nicht erlaubt sind. Diese Endungen gelten zusätzlich zur Liste oben, aber nur für Mails. Gefährliche Typen bleiben gesperrt.</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
|
||
<?php foreach (explode(',', $uploadExtensions) as $ext): $ext = trim($ext); if (!$ext) continue; ?>
|
||
<span style="background:var(--primary-lt);color:var(--primary);border-radius:20px;padding:3px 10px;font-size:.78rem;font-weight:600"><?= e($ext) ?></span>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<label style="margin-top:16px">Maximale Dateigröße</label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<input type="number" name="upload_max_size_mb" value="<?= e($uploadMaxSize) ?>" min="1" max="100" style="width:90px"> MB
|
||
</div>
|
||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ── TAB: FOOTER ────────────────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'footer'): ?>
|
||
<div class="card">
|
||
<h3>Datenschutz & Impressum</h3>
|
||
<p class="muted" style="font-size:.85rem;margin-bottom:20px">Externer Link <em>oder</em> HTML-Inhalt (öffnet Popup). Ist ein Link gesetzt, wird das HTML-Feld ignoriert.</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="save_footer_pages">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||
<div>
|
||
<div style="font-weight:700;font-size:.88rem;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)">🔒 Datenschutzerklärung</div>
|
||
<label>Beschriftung im Footer</label>
|
||
<input type="text" name="footer_privacy_label" value="<?= e($footerPrivacyLabel) ?>" placeholder="Datenschutz">
|
||
<label style="margin-top:10px">Externer Link (optional)</label>
|
||
<input type="text" name="footer_privacy_url" value="<?= e($footerPrivacyUrl) ?>" placeholder="https://..." style="width:100%">
|
||
<label style="margin-top:10px">HTML-Inhalt (Popup)</label>
|
||
<textarea name="footer_privacy_html" rows="8" style="font-family:monospace;font-size:.8rem;width:100%"><?= e($footerPrivacyHtml) ?></textarea>
|
||
</div>
|
||
<div>
|
||
<div style="font-weight:700;font-size:.88rem;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border)">📄 Impressum</div>
|
||
<label>Beschriftung im Footer</label>
|
||
<input type="text" name="footer_imprint_label" value="<?= e($footerImprintLabel) ?>" placeholder="Impressum">
|
||
<label style="margin-top:10px">Externer Link (optional)</label>
|
||
<input type="text" name="footer_imprint_url" value="<?= e($footerImprintUrl) ?>" placeholder="https://..." style="width:100%">
|
||
<label style="margin-top:10px">HTML-Inhalt (Popup)</label>
|
||
<textarea name="footer_imprint_html" rows="8" style="font-family:monospace;font-size:.8rem;width:100%"><?= e($footerImprintHtml) ?></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- ── TAB: DATENSCHUTZ ──────────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'datenschutz'): ?>
|
||
<?php
|
||
$retEnabled = get_setting('retention_enabled', '0') === '1';
|
||
$retMonths = (int)(get_setting('retention_months', '24'));
|
||
$ipAnon = get_setting('ip_anonymize', '0') === '1';
|
||
// Vorschau: wie viele Tickets wären betroffen?
|
||
$previewSql = $pdo->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();
|
||
?>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||
|
||
<div class="card">
|
||
<h3>Automatische Datenlöschung</h3>
|
||
<p class="muted" style="font-size:.83rem;margin-bottom:16px">
|
||
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.
|
||
</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_privacy">
|
||
|
||
<label style="display:flex;align-items:center;gap:10px;font-weight:500;cursor:pointer;margin-bottom:18px">
|
||
<input type="checkbox" name="retention_enabled" value="1" style="width:auto" <?= $retEnabled ? 'checked' : '' ?>>
|
||
Automatische Anonymisierung aktivieren
|
||
</label>
|
||
|
||
<label for="ret_months">Aufbewahrungsdauer nach Schließung</label>
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:18px">
|
||
<input type="number" id="ret_months" name="retention_months" value="<?= $retMonths ?>" min="1" max="120" style="width:100px">
|
||
<span class="muted" style="font-size:.85rem">Monate</span>
|
||
</div>
|
||
|
||
<label style="display:flex;align-items:center;gap:10px;font-weight:500;cursor:pointer;margin-bottom:18px">
|
||
<input type="checkbox" name="ip_anonymize" value="1" style="width:auto" <?= $ipAnon ? 'checked' : '' ?>>
|
||
IP-Adressen sofort beim Speichern kürzen (letztes Oktett entfernen)
|
||
</label>
|
||
|
||
<label style="display:flex;align-items:center;gap:10px;font-weight:500;cursor:pointer;margin-bottom:6px">
|
||
<input type="checkbox" name="gdpr_tools_enabled" value="1" style="width:auto" <?= gdpr_tools_enabled() ? 'checked' : '' ?>>
|
||
Auskunft und Löschung auf Anfrage im Kundenprofil anbieten (nur Administratoren)
|
||
</label>
|
||
<p class="muted" style="font-size:.78rem;margin:0 0 18px 28px">Zeigt im Kundenprofil die Knöpfe „Alle Daten exportieren“ und „Kundendaten löschen“. Jede Nutzung wird im Audit-Log protokolliert.</p>
|
||
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn">Speichern</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h3>Status & Vorschau</h3>
|
||
<div style="margin-bottom:20px">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
||
<span style="width:10px;height:10px;border-radius:50%;background:<?= $retEnabled ? '#22c55e' : '#94a3b8' ?>;flex-shrink:0"></span>
|
||
<span style="font-size:.85rem;font-weight:500"><?= $retEnabled ? 'Aktiv – Anonymisierung läuft täglich via Cron' : 'Deaktiviert' ?></span>
|
||
</div>
|
||
<p class="muted" style="font-size:.82rem;margin:0">
|
||
Aktuell würden <strong><?= $previewCount ?></strong> Ticket(s) anonymisiert
|
||
(geschlossen/gelöst, älter als <?= $retMonths ?> Monate).
|
||
</p>
|
||
</div>
|
||
|
||
<h3 style="margin-top:0">Was wird anonymisiert?</h3>
|
||
<ul style="font-size:.83rem;color:var(--ink-soft);line-height:1.8;padding-left:18px;margin:0 0 20px">
|
||
<li><strong>Name</strong> → <code>Anonymisiert</code></li>
|
||
<li><strong>E-Mail</strong> → <code>anon_[hash]@deleted.local</code></li>
|
||
<li><strong>IP-Adresse</strong> → <code>0.0.0.0</code></li>
|
||
<li><strong>Ticket-Nachrichten</strong> → bleiben erhalten</li>
|
||
<li><strong>Ticket-Nummer, Betreff, Status</strong> → bleiben erhalten</li>
|
||
</ul>
|
||
|
||
<h3 style="margin-top:0">Crontab-Eintrag</h3>
|
||
<pre style="background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:.76rem;margin:0 0 16px">0 3 * * * php /pfad/zu/deskhub/cron/anonymize_data.php</pre>
|
||
|
||
<form method="post" onsubmit="return confirm('Anonymisierung jetzt ausführen? Dies kann nicht rückgängig gemacht werden.')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="run_anonymize_now">
|
||
<button type="submit" class="btn secondary" style="font-size:.83rem">
|
||
▶ Jetzt ausführen (<?= $previewCount ?> Tickets)
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ── TAB: CRON ──────────────────────────────────────────────────────────── -->
|
||
<?php elseif ($tab === 'cron'): ?>
|
||
<?php
|
||
$pseudoCronEnabled = get_setting('pseudo_cron_enabled', '1') === '1';
|
||
$pseudoCronGate = get_setting('pseudo_cron_gate');
|
||
$pseudoCronLastTick = cron_last_run_label($pseudoCronGate);
|
||
?>
|
||
<div class="card" style="margin-bottom:20px">
|
||
<h3>🔄 Automatischer Hintergrund-Betrieb (ohne Server-Cronjob)</h3>
|
||
<p class="muted" style="font-size:.85rem;margin:6px 0 14px">
|
||
Läuft komplett eingebaut, ganz ohne echten Server-Cronjob und ohne externen Dienst:
|
||
bei jeder Anmeldung/Nutzung im Backend <strong>und</strong> in der App wird im Hintergrund geprüft,
|
||
ob einer der 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.
|
||
</p>
|
||
<div style="display:flex; align-items:center; gap:16px; flex-wrap:wrap">
|
||
<form method="post" style="display:flex; align-items:center; gap:10px">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="save_pseudo_cron">
|
||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;cursor:pointer;margin:0">
|
||
<input type="checkbox" name="pseudo_cron_enabled" value="1" style="width:auto" <?= $pseudoCronEnabled?'checked':'' ?> onchange="this.form.submit()">
|
||
Automatischen Hintergrund-Betrieb aktiv halten
|
||
</label>
|
||
</form>
|
||
<span class="muted" style="font-size:.82rem">
|
||
Status: <?= $pseudoCronEnabled ? '<span style="color:var(--success)">● aktiv</span>' : '<span style="color:var(--muted)">○ deaktiviert</span>' ?>
|
||
· Letzte automatische Prüfung: <?= e($pseudoCronLastTick) ?>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
|
||
<div class="card">
|
||
<h3>Cron-Status</h3>
|
||
<p class="muted" style="font-size:.82rem;margin-bottom:12px">Jeder Job kann hier zusätzlich jederzeit sofort manuell gestartet werden.</p>
|
||
<?php
|
||
$cronLastRun = [
|
||
'fetch_emails' => $cronFetch,
|
||
'check_sla' => $cronSla,
|
||
'check_escalations' => $cronEscalations,
|
||
'process_recurring' => $cronRecurring,
|
||
'process_backups' => $cronBackups,
|
||
'anonymize_data' => $cronAnonymize,
|
||
'auto_close_tickets'=> $cronAutoClose,
|
||
'process_snoozes' => $cronSnoozes,
|
||
'process_mail_queue' => cron_last_run_label(get_setting('cron_process_mail_queue_last_run')),
|
||
'remind_customers' => $cronRemind,
|
||
'run_plugins' => $cronPlugins,
|
||
'send_csat' => $cronCsat,
|
||
'status_checks' => cron_last_run_label(get_setting('cron_status_checks_last_run')),
|
||
'send_scheduled_replies' => cron_last_run_label(get_setting('cron_send_scheduled_replies_last_run')),
|
||
'send_report_schedules' => cron_last_run_label(get_setting('cron_send_report_schedules_last_run')),
|
||
];
|
||
?>
|
||
<table style="width:100%">
|
||
<tbody>
|
||
<?php foreach ($cronJobs as $jobKey => $jobMeta): ?>
|
||
<tr>
|
||
<td><?= e($jobMeta['label']) ?></td>
|
||
<td class="muted" style="text-align:right; white-space:nowrap"><?= e($cronLastRun[$jobKey]) ?></td>
|
||
<td style="text-align:right; width:1%">
|
||
<form method="post" style="display:inline" onsubmit="return confirm('<?= e($jobMeta['label']) ?> jetzt manuell ausführen?')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="run_cron_now">
|
||
<input type="hidden" name="job" value="<?= e($jobKey) ?>">
|
||
<button type="submit" class="btn secondary" style="padding:4px 10px; font-size:.78rem">Jetzt ausführen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Echter Server-Cronjob</h3>
|
||
<?php
|
||
$crontabCheck = crontab_capability_check();
|
||
$crontabInstalled = $crontabCheck['available'] && crontab_is_installed();
|
||
?>
|
||
<?php if ($crontabCheck['available']): ?>
|
||
<p class="muted" style="font-size:.82rem;margin-bottom:10px">
|
||
✅ 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.
|
||
</p>
|
||
<p style="margin-bottom:14px">
|
||
Status:
|
||
<?php if ($crontabInstalled): ?>
|
||
<strong style="color:var(--success)">● Installiert</strong>
|
||
<?php else: ?>
|
||
<strong class="muted">○ Nicht installiert</strong>
|
||
<?php endif; ?>
|
||
</p>
|
||
<div class="btn-row" style="margin-bottom:14px">
|
||
<form method="post" onsubmit="return confirm('DeskHub-Cronjobs jetzt fest in die Server-Crontab eintragen? Bestehende, nicht von DeskHub verwaltete Einträge bleiben erhalten.')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="install_crontab">
|
||
<button type="submit" class="btn"><?= $crontabInstalled ? 'Neu installieren / aktualisieren' : 'Jetzt installieren' ?></button>
|
||
</form>
|
||
<?php if ($crontabInstalled): ?>
|
||
<form method="post" onsubmit="return confirm('DeskHub-Block wieder aus der Server-Crontab entfernen?')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="remove_crontab">
|
||
<button type="submit" class="btn secondary">Entfernen</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php else: ?>
|
||
<p class="muted" style="font-size:.82rem;margin-bottom:10px">
|
||
❌ Auf diesem Server nicht möglich: <?= e($crontabCheck['reason']) ?><br>
|
||
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.
|
||
</p>
|
||
<?php endif; ?>
|
||
<details style="margin-top:4px">
|
||
<summary style="cursor:pointer;font-size:.82rem;color:var(--muted)">Crontab-Zeilen anzeigen</summary>
|
||
<pre style="background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:12px;font-size:.76rem;overflow-x:auto;white-space:pre-wrap;margin-top:8px"><?= e(crontab_marker_block()) ?></pre>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php require __DIR__ . '/_footer.php'; ?>
|