diff --git a/DeskHub-Webseite/includes/integrations.php b/DeskHub-Webseite/includes/integrations.php
index d27f359b..6de51e25 100644
--- a/DeskHub-Webseite/includes/integrations.php
+++ b/DeskHub-Webseite/includes/integrations.php
@@ -58,7 +58,7 @@ function api_key_verify(PDO $pdo, string $key, string $ip): ?array
* @return array{number:string,id:int}
* @throws AdminModelException bei ungültigen Eingaben
*/
-function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
+function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts = []): array
{
$name = trim((string) ($in['name'] ?? ''));
$email = trim((string) ($in['email'] ?? ''));
@@ -110,7 +110,8 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp): array
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
->execute([$ticketId, 'customer', $message, $now]);
- log_activity($pdo, $ticketId, null, 'Ticket über die API erstellt');
+ log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt'));
+ if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']);
$assignTo = $ruleResult['assign_staff'];
if ($assignTo === null) {
diff --git a/DeskHub-Webseite/includes/mailer.php b/DeskHub-Webseite/includes/mailer.php
index a1105260..5afa38af 100644
--- a/DeskHub-Webseite/includes/mailer.php
+++ b/DeskHub-Webseite/includes/mailer.php
@@ -318,6 +318,15 @@ function notify_customer_new_reply(array $ticket, string $message, string $signa
}
}
+/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
+function email_customer_reminder_default_body(): string
+{
+ return '
Brauchen Sie noch Hilfe? '
+ . 'Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket #{ticket_number} („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.
'
+ . '{close_note}'
+ . 'Ticket ansehen
';
+}
+
function notify_customer_ticket_created(array $ticket): void
{
[$subject, $body] = render_email_template('ticket_created', [
diff --git a/DeskHub-Webseite/includes/notifications.php b/DeskHub-Webseite/includes/notifications.php
index bb36bfae..042d9239 100644
--- a/DeskHub-Webseite/includes/notifications.php
+++ b/DeskHub-Webseite/includes/notifications.php
@@ -85,6 +85,15 @@ function notify_new_ticket_channels(array $ticket, ?string $departmentEmail): vo
function notify_new_reply_channels(array $ticket, string $excerpt): void
{
fcm_notify_new_reply($ticket); // Push auf die Android-App (läuft nach dem Senden der Seite)
+ try {
+ // Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon
+ $pdo = get_db();
+ ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)');
+ $pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL WHERE id = ?')->execute([$ticket['id']]);
+ ticket_followers_notify($ticket, 'Neue Kundenantwort');
+ } catch (Throwable $e) {
+ // Datenbank noch nicht aktualisiert: nicht schlimm
+ }
$short = mb_strlen($excerpt) > 200 ? mb_substr($excerpt, 0, 200) . '…' : $excerpt;
$text = "💬 Neue Kundenantwort zu Ticket #{$ticket['ticket_number']}\n"
. "Betreff: {$ticket['subject']}\n"
diff --git a/DeskHub-Webseite/includes/pseudo_cron.php b/DeskHub-Webseite/includes/pseudo_cron.php
index 20505320..dee1b65d 100644
--- a/DeskHub-Webseite/includes/pseudo_cron.php
+++ b/DeskHub-Webseite/includes/pseudo_cron.php
@@ -38,6 +38,8 @@ function pseudo_cron_job_definitions(): array
'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'],
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
+ 'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
+ 'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
];
}
diff --git a/DeskHub-Webseite/includes/staff_activity.php b/DeskHub-Webseite/includes/staff_activity.php
index aadd274a..21e80528 100644
--- a/DeskHub-Webseite/includes/staff_activity.php
+++ b/DeskHub-Webseite/includes/staff_activity.php
@@ -84,6 +84,18 @@ function staff_set_status(PDO $pdo, int $staffId, string $status, string $note =
return true;
}
+/**
+ * Ist die aktuelle Anfrage das Laden einer Seite (im Gegensatz zu einem Abruf, den eine geöffnete Seite im Hintergrund per fetch stellt)?
+ * Egal, ob der Nutzer sie ausgelöst hat oder der Browser selbst (Tab wird nach dem Ruhezustand neu geladen, Seite lädt sich per Skript neu).
+ */
+function staff_is_document_request(): bool
+{
+ $mode = $_SERVER['HTTP_SEC_FETCH_MODE'] ?? '';
+ if ($mode !== '') return $mode === 'navigate' || ($_SERVER['HTTP_SEC_FETCH_DEST'] ?? '') === 'document';
+ // Ältere Browser ohne Sec-Fetch-Header: Seitenaufrufe erkennt man daran, dass kein X-Requested-With gesetzt ist und HTML erwartet wird
+ return empty($_SERVER['HTTP_X_REQUESTED_WITH']) && str_contains((string) ($_SERVER['HTTP_ACCEPT'] ?? ''), 'text/html');
+}
+
/** Ist die aktuelle Anfrage eine echte Seitennavigation des Nutzers (und kein Hintergrund-Abruf per fetch)? */
function staff_is_user_navigation(): bool
{
diff --git a/DeskHub-Webseite/includes/ticket_extras.php b/DeskHub-Webseite/includes/ticket_extras.php
new file mode 100644
index 00000000..d475c048
--- /dev/null
+++ b/DeskHub-Webseite/includes/ticket_extras.php
@@ -0,0 +1,204 @@
+ Beschriftung). */
+function ticket_snooze_options(): array
+{
+ return ['1h' => 'In 1 Stunde', '3h' => 'In 3 Stunden', 'tomorrow' => 'Morgen früh (8:00 Uhr)', '3d' => 'In 3 Tagen', '1w' => 'In 1 Woche', 'custom' => 'Zu einem Zeitpunkt …'];
+}
+
+/** Rechnet die Auswahl in einen Zeitpunkt um (Y-m-d H:i:s); null, wenn ungültig oder nicht in der Zukunft (höchstens 1 Jahr). */
+function ticket_snooze_until(string $choice, string $custom = ''): ?string
+{
+ $now = time();
+ $ts = match ($choice) {
+ '1h' => $now + 3600,
+ '3h' => $now + 3 * 3600,
+ 'tomorrow' => (int) strtotime('tomorrow 08:00'),
+ '3d' => $now + 3 * 86400,
+ '1w' => $now + 7 * 86400,
+ 'custom' => $custom !== '' ? (int) strtotime(str_replace('T', ' ', $custom)) : 0,
+ default => 0,
+ };
+ if ($ts <= $now + 30 || $ts > $now + 366 * 86400) return null;
+ return date('Y-m-d H:i:s', $ts);
+}
+
+function ticket_snooze_set(PDO $pdo, int $ticketId, int $staffId, string $until): void
+{
+ $now = db_now();
+ $pdo->prepare('UPDATE tickets SET snoozed_until = ?, snoozed_by = ?, updated_at = updated_at WHERE id = ?')->execute([$until, $staffId, $ticketId]);
+ log_activity($pdo, $ticketId, $staffId, 'Wiedervorlage bis ' . date('d.m.Y H:i', (int) strtotime($until)) . ' Uhr');
+}
+
+function ticket_snooze_clear(PDO $pdo, int $ticketId, ?int $staffId, string $reason): void
+{
+ $stmt = $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ? AND snoozed_until IS NOT NULL');
+ $stmt->execute([$ticketId]);
+ if ($stmt->rowCount() > 0) log_activity($pdo, $ticketId, $staffId, $reason);
+}
+
+/** Gibt es die Spalte der Wiedervorlage schon? (Solange die Datenbank nicht aktualisiert ist, bleiben die Listen trotzdem benutzbar.) */
+function ticket_snooze_available(): bool
+{
+ static $ok = null;
+ if ($ok === null) {
+ try { get_db()->query('SELECT snoozed_until FROM tickets LIMIT 1'); $ok = true; } catch (Throwable $e) { $ok = false; }
+ }
+ return $ok;
+}
+
+/** SQL-Bedingung: Ticket ist gerade nicht in der Wiedervorlage (für Listen und Zähler). */
+function ticket_not_snoozed_sql(string $alias = 't'): string
+{
+ if (!ticket_snooze_available()) return '1=1';
+ return "({$alias}.snoozed_until IS NULL OR {$alias}.snoozed_until <= '" . db_now() . "')";
+}
+
+/**
+ * Holt fällige Wiedervorlagen zurück: Das Ticket erscheint wieder in den Listen, der Mitarbeiter bekommt eine Benachrichtigung.
+ *
+ * @return int Anzahl der zurückgeholten Tickets
+ */
+function ticket_snooze_wake_due(PDO $pdo): int
+{
+ try {
+ $stmt = $pdo->prepare('SELECT * FROM tickets WHERE snoozed_until IS NOT NULL AND snoozed_until <= ?');
+ $stmt->execute([db_now()]);
+ $due = $stmt->fetchAll();
+ } catch (Throwable $e) {
+ return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert)
+ }
+ foreach ($due as $t) {
+ $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]);
+ log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig');
+ $to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0));
+ if ($to > 0) {
+ fcm_notify_staff([$to], fcm_data('ticket_snooze', $t), (int) $t['department_id']);
+ if (file_exists(__DIR__ . '/push.php')) {
+ require_once __DIR__ . '/push.php';
+ push_to_staff($to, 'Wiedervorlage: #' . $t['ticket_number'], mb_strimwidth((string) $t['subject'], 0, 60, '…'), '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number']));
+ }
+ }
+ }
+ return count($due);
+}
+
+// ── Beobachter ───────────────────────────────────────────────────────────────
+
+function ticket_follow(PDO $pdo, int $ticketId, int $staffId): void
+{
+ $c = $pdo->prepare('SELECT COUNT(*) FROM ticket_followers WHERE ticket_id = ? AND staff_id = ?');
+ $c->execute([$ticketId, $staffId]);
+ if ((int) $c->fetchColumn() === 0) {
+ $pdo->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES (?,?,?)')->execute([$ticketId, $staffId, db_now()]);
+ }
+}
+
+function ticket_unfollow(PDO $pdo, int $ticketId, int $staffId): void
+{
+ $pdo->prepare('DELETE FROM ticket_followers WHERE ticket_id = ? AND staff_id = ?')->execute([$ticketId, $staffId]);
+}
+
+/** @return array */
+function ticket_followers(PDO $pdo, int $ticketId): array
+{
+ try {
+ $stmt = $pdo->prepare('SELECT s.id, s.name FROM ticket_followers f JOIN staff s ON s.id = f.staff_id WHERE f.ticket_id = ? AND s.is_active = 1 ORDER BY s.name');
+ $stmt->execute([$ticketId]);
+ return $stmt->fetchAll();
+ } catch (Throwable $e) {
+ return [];
+ }
+}
+
+/**
+ * Benachrichtigt die Beobachter eines Tickets (nicht den, der es ausgelöst hat, und nicht den zugewiesenen Mitarbeiter, der ohnehin
+ * Bescheid bekommt). $what ist ein kurzer Text wie "Neue Kundenantwort".
+ */
+function ticket_followers_notify(array $ticket, string $what, ?int $actorId = null): void
+{
+ if (empty($ticket['id'])) return;
+ $pdo = get_db();
+ $ids = array_map(fn($f) => (int) $f['id'], ticket_followers($pdo, (int) $ticket['id']));
+ $skip = array_filter([$actorId, (int) ($ticket['assigned_staff_id'] ?? 0)]);
+ $ids = array_values(array_diff($ids, $skip));
+ if (!$ids) return;
+ $data = fcm_data('ticket_follow', $ticket);
+ $data['body'] = $what . ' · #' . ($ticket['ticket_number'] ?? '');
+ fcm_notify_staff($ids, $data, (int) ($ticket['department_id'] ?? 0));
+ if (file_exists(__DIR__ . '/push.php')) {
+ require_once __DIR__ . '/push.php';
+ foreach ($ids as $id) {
+ push_to_staff($id, 'Beobachtet: #' . ($ticket['ticket_number'] ?? ''), $what, '/staff/ticket_view.php?n=' . urlencode((string) ($ticket['ticket_number'] ?? '')));
+ }
+ }
+}
+
+// ── Ähnliche Tickets und Artikel ─────────────────────────────────────────────
+
+/**
+ * Passende Artikel der Wissensdatenbank und bereits gelöste, ähnliche Tickets zu Betreff (und Kundentext) eines Tickets.
+ *
+ * @param int[]|null $deptIds sichtbare Abteilungen des Mitarbeiters (null = alle)
+ * @return array{articles:array,tickets:array}
+ */
+function ticket_similar(PDO $pdo, array $ticket, ?array $deptIds = null, int $limit = 4): array
+{
+ require_once __DIR__ . '/assistant.php';
+ $q = assistant_tokens((string) ($ticket['subject'] ?? ''));
+ $out = ['articles' => [], 'tickets' => []];
+ if (count($q) < 1) return $out;
+ $need = max(1, (int) ceil(count($q) / 2));
+
+ foreach ($pdo->query('SELECT id, title, slug, body FROM kb_articles WHERE is_published = 1 ORDER BY id DESC LIMIT 500')->fetchAll() as $a) {
+ $title = array_flip(assistant_tokens($a['title']));
+ $body = array_flip(assistant_tokens(mb_substr(strip_tags((string) $a['body']), 0, 20000)));
+ $score = 0.0; $hit = 0;
+ foreach ($q as $t) {
+ if (isset($title[$t])) { $score += 4; $hit++; }
+ elseif (isset($body[$t])) { $score += 1.5; $hit++; }
+ }
+ if ($hit >= $need && $score >= 2.5) $out['articles'][] = ['score' => $score, 'title' => $a['title'], 'url' => '/kb_article.php?slug=' . rawurlencode((string) $a['slug'])];
+ }
+ usort($out['articles'], fn($a, $b) => $b['score'] <=> $a['score']);
+ $out['articles'] = array_slice($out['articles'], 0, 3);
+
+ $sql = "SELECT id, ticket_number, subject, status FROM tickets WHERE id <> ? AND is_spam = 0 AND status IN ('resolved','closed')";
+ $params = [(int) $ticket['id']];
+ if ($deptIds !== null) {
+ if (!$deptIds) return $out;
+ $sql .= ' AND department_id IN (' . implode(',', array_fill(0, count($deptIds), '?')) . ')';
+ array_push($params, ...$deptIds);
+ }
+ $stmt = $pdo->prepare($sql . ' ORDER BY id DESC LIMIT 800');
+ $stmt->execute($params);
+ foreach ($stmt->fetchAll() as $t) {
+ $words = array_flip(assistant_tokens((string) $t['subject']));
+ $hit = 0;
+ foreach ($q as $w) if (isset($words[$w])) $hit++;
+ if ($hit >= max(2, $need) || ($hit >= 1 && count($q) === 1)) $out['tickets'][] = ['score' => $hit, 'number' => $t['ticket_number'], 'subject' => $t['subject'], 'status' => $t['status']];
+ }
+ usort($out['tickets'], fn($a, $b) => $b['score'] <=> $a['score']);
+ $out['tickets'] = array_slice($out['tickets'], 0, $limit);
+ return $out;
+}
+
+// ── Erinnerung an Kunden ─────────────────────────────────────────────────────
+
+function customer_reminder_enabled(): bool
+{
+ return get_setting('remind_enabled', '0') === '1';
+}
+
+function customer_reminder_days(): int
+{
+ return max(1, min(60, (int) get_setting('remind_after_days', '3')));
+}
diff --git a/DeskHub-Webseite/includes/web_handoff.php b/DeskHub-Webseite/includes/web_handoff.php
new file mode 100644
index 00000000..76c153d1
--- /dev/null
+++ b/DeskHub-Webseite/includes/web_handoff.php
@@ -0,0 +1,84 @@
+
+ */
+function web_handoff_pages(array $staff): array
+{
+ $isAdmin = ($staff['role'] ?? '') === 'admin';
+ $perm = fn(string $p): bool => $isAdmin || has_permission($staff, $p);
+ $all = [
+ // Alle Mitarbeiter
+ ['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true],
+ ['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true],
+ // Mit Recht
+ ['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')],
+ ['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')],
+ // Nur Administratoren
+ ['settings', 'Einstellungen', '/staff/settings.php', 'Verwaltung', $isAdmin],
+ ['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $isAdmin],
+ ['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $isAdmin],
+ ['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin],
+ ['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin],
+ ['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin],
+ ['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin],
+ ['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin],
+ ['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin],
+ ['backup', 'Backup', '/staff/backup.php', 'System', $isAdmin],
+ ['update', 'System-Update', '/staff/update.php', 'System', $isAdmin],
+ ['security_check', 'Sicherheitsprüfung', '/staff/security_check.php', 'System', $isAdmin],
+ ['import_osticket', 'Import aus osTicket', '/staff/import_osticket.php', 'System', $isAdmin],
+ ];
+ $out = [];
+ foreach ($all as [$key, $label, $path, $group, $allowed]) {
+ if ($allowed) $out[] = ['key' => $key, 'label' => $label, 'path' => $path, 'group' => $group];
+ }
+ return $out;
+}
+
+/** Darf diese Adresse aus der App geöffnet werden? (eine Seite der Liste oder ein einzelnes Ticket) */
+function web_handoff_path_allowed(array $staff, string $path): bool
+{
+ if (preg_match('#^/staff/(ticket_view|ticket_print|ticket_split)\.php\?n=[A-Za-z0-9._-]{1,40}$#', $path)) return true;
+ foreach (web_handoff_pages($staff) as $p) if ($p['path'] === $path) return true;
+ return false;
+}
+
+/** Erzeugt die einmalige Anmelde-Adresse für die Seite $path. */
+function web_handoff_create(PDO $pdo, array $staff, string $path): ?string
+{
+ if (!web_handoff_path_allowed($staff, $path)) return null;
+ $token = bin2hex(random_bytes(32));
+ $now = db_now();
+ $pdo->prepare('DELETE FROM web_login_tokens WHERE expires_at < ?')->execute([date('Y-m-d H:i:s', time() - 3600)]);
+ $pdo->prepare('INSERT INTO web_login_tokens (staff_id, token_hash, path, created_at, expires_at) VALUES (?,?,?,?,?)')
+ ->execute([(int) $staff['id'], hash('sha256', $token), $path, $now, date('Y-m-d H:i:s', time() + 60)]);
+ return rtrim(base_url(), '/') . '/staff/app_login.php?t=' . $token;
+}
+
+/** Löst die Anmelde-Adresse ein (einmalig). @return array{staff:array,path:string}|null */
+function web_handoff_consume(PDO $pdo, string $token): ?array
+{
+ if (!preg_match('/^[a-f0-9]{64}$/', $token)) return null;
+ $hash = hash('sha256', $token);
+ $upd = $pdo->prepare('UPDATE web_login_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at >= ?');
+ $upd->execute([db_now(), $hash, db_now()]);
+ if ($upd->rowCount() !== 1) return null; // schon benutzt, abgelaufen oder unbekannt
+ $row = $pdo->prepare('SELECT t.path AS handoff_path, s.* FROM web_login_tokens t JOIN staff s ON s.id = t.staff_id WHERE t.token_hash = ? AND s.is_active = 1');
+ $row->execute([$hash]);
+ $r = $row->fetch();
+ if (!$r) return null;
+ $path = (string) $r['handoff_path'];
+ unset($r['handoff_path']);
+ return ['staff' => $r, 'path' => $path];
+}
diff --git a/DeskHub-Webseite/staff/app_login.php b/DeskHub-Webseite/staff/app_login.php
new file mode 100644
index 00000000..08538b1f
--- /dev/null
+++ b/DeskHub-Webseite/staff/app_login.php
@@ -0,0 +1,24 @@
+ 0) { $c = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1'); $c->execute([$dept]); if (!(int) $c->fetchColumn()) $dept = 0; }
+ if ($prio > 0) { $c = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE id = ?'); $c->execute([$prio]); if (!(int) $c->fetchColumn()) $prio = 0; }
+ set_setting('assistant_enabled', !empty($_POST['assistant_enabled']) ? '1' : '0');
+ set_setting('assistant_mode', $mode);
+ set_setting('assistant_name', mb_substr(trim(strip_tags((string) ($_POST['assistant_name'] ?? ''))), 0, 60));
+ set_setting('assistant_greeting', mb_substr(trim(strip_tags((string) ($_POST['assistant_greeting'] ?? ''))), 0, 500));
+ set_setting('assistant_no_answer', mb_substr(trim(strip_tags((string) ($_POST['assistant_no_answer'] ?? ''))), 0, 500));
+ set_setting('assistant_department_id', (string) $dept);
+ set_setting('assistant_priority_id', (string) $prio);
+ set_setting('assistant_log_days', (string) max(0, min(3650, (int) ($_POST['assistant_log_days'] ?? 90))));
+ audit_log($admin['id'], 'Einstellungen des digitalen Assistenten geändert');
+ flash('success', 'Einstellungen gespeichert.');
+ redirect('/staff/assistant.php');
+ }
+
+ if ($action === 'save_answer') {
+ $id = (int) ($_POST['id'] ?? 0);
+ $title = trim(mb_substr((string) ($_POST['title'] ?? ''), 0, 255));
+ $keywords = trim(mb_substr((string) ($_POST['keywords'] ?? ''), 0, 1000));
+ $answer = trim(mb_substr((string) ($_POST['answer'] ?? ''), 0, 4000));
+ $active = !empty($_POST['is_active']) ? 1 : 0;
+ if ($title === '' || $answer === '') {
+ $errors[] = 'Bitte eine Frage (Titel) und die Antwort angeben.';
+ } else {
+ $now = db_now();
+ if ($id > 0) {
+ $pdo->prepare('UPDATE assistant_answers SET title=?, keywords=?, answer=?, is_active=?, updated_at=? WHERE id=?')->execute([$title, $keywords ?: null, $answer, $active, $now, $id]);
+ audit_log($admin['id'], "Antwort des digitalen Assistenten geändert: {$title}");
+ flash('success', 'Antwort gespeichert.');
+ } else {
+ $pdo->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$title, $keywords ?: null, $answer, $active, $now, $now]);
+ audit_log($admin['id'], "Antwort des digitalen Assistenten angelegt: {$title}");
+ flash('success', 'Antwort angelegt.');
+ }
+ redirect('/staff/assistant.php?tab=answers');
+ }
+ }
+
+ if ($action === 'delete_answer') {
+ $id = (int) ($_POST['id'] ?? 0);
+ $t = $pdo->prepare('SELECT title FROM assistant_answers WHERE id = ?');
+ $t->execute([$id]);
+ $title = $t->fetchColumn();
+ if ($title !== false) {
+ $pdo->prepare('DELETE FROM assistant_answers WHERE id = ?')->execute([$id]);
+ audit_log($admin['id'], "Antwort des digitalen Assistenten gelöscht: {$title}");
+ flash('success', 'Antwort gelöscht.');
+ }
+ redirect('/staff/assistant.php?tab=answers');
+ }
+
+ if ($action === 'save_synonyms') {
+ $text = mb_substr(trim(strip_tags((string) ($_POST['assistant_synonyms'] ?? ''))), 0, 5000);
+ set_setting('assistant_synonyms', $text);
+ audit_log($admin['id'], 'Gleichbedeutende Wörter des digitalen Assistenten geändert');
+ flash('success', 'Gleichbedeutende Wörter gespeichert (' . count(assistant_custom_synonyms($text)) . ' Gruppen).');
+ redirect('/staff/assistant.php');
+ }
+
+ if ($action === 'clear_log') {
+ try {
+ $n = $pdo->exec('DELETE FROM assistant_log');
+ audit_log($admin['id'], 'Fragen-Log des digitalen Assistenten geleert');
+ flash('success', 'Fragen-Log geleert (' . (int) $n . ' Einträge).');
+ } catch (Throwable $e) {
+ flash('error', 'Das Log konnte nicht geleert werden. Bitte die Datenbank aktualisieren.');
+ }
+ redirect('/staff/assistant.php?tab=log');
+ }
+
+ if ($action === 'test') {
+ $testQuestion = trim(mb_substr((string) ($_POST['question'] ?? ''), 0, 300));
+ $testResult = $testQuestion !== '' ? (assistant_search($pdo, $testQuestion, false) ?: false) : null;
+ }
+}
+
+$answers = [];
+$dbOutdated = false;
+try {
+ $answers = $pdo->query('SELECT * FROM assistant_answers ORDER BY title')->fetchAll();
+} catch (Throwable $e) {
+ $dbOutdated = true;
+}
+$edit = null;
+if (!empty($_GET['edit'])) {
+ foreach ($answers as $a) if ((int) $a['id'] === (int) $_GET['edit']) $edit = $a;
+}
+$showForm = isset($_GET['new']) || $edit || $errors;
+$tab = in_array($_GET['tab'] ?? '', ['settings', 'answers', 'log'], true) ? $_GET['tab'] : 'settings';
+if (isset($_GET['edit']) || isset($_GET['new']) || $errors || ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_answer')) $tab = 'answers';
+if (isset($_GET['lr']) || isset($_GET['lp']) || isset($_GET['lq']) || isset($_GET['lpage'])) $tab = 'log';
+$prefillTitle = (!$edit && empty($_POST) && isset($_GET['title'])) ? mb_substr(trim((string) $_GET['title']), 0, 255) : '';
+
+$logFilter = [
+ 'result' => (string) ($_GET['lr'] ?? ''),
+ 'period' => in_array($_GET['lp'] ?? '', ['today', '7', '30', 'all'], true) ? $_GET['lp'] : '30',
+ 'q' => trim((string) ($_GET['lq'] ?? '')),
+];
+$logResults = assistant_log_filters();
+$logPage = ['rows' => [], 'total' => 0, 'pages' => 1, 'page' => 1];
+$logStats = array_fill_keys(array_keys($logResults), 0);
+$logTop = [];
+$logOutdated = false;
+try {
+ $logPage = assistant_log_list($pdo, $logFilter, (int) ($_GET['lpage'] ?? 1));
+ $logStats = assistant_log_stats($pdo, $logFilter['period']);
+ $logTop = assistant_log_unanswered($pdo, $logFilter['period']);
+} catch (Throwable $e) {
+ $logOutdated = true;
+}
+$logQuery = static fn(array $over = []) => '/staff/assistant.php?' . http_build_query(array_filter(array_merge(['tab' => 'log', 'lr' => $logFilter['result'], 'lp' => $logFilter['period'], 'lq' => $logFilter['q']], $over), static fn($v) => $v !== '' && $v !== null));
+$departments = $pdo->query('SELECT id, name FROM departments WHERE is_active = 1 ORDER BY name')->fetchAll();
+$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
+$texts = assistant_texts();
+$enabled = assistant_enabled();
+$activeNow = assistant_active($pdo);
+$liveChatOn = get_setting('live_chat_enabled', '0') === '1';
+$kbOn = get_setting('kb_enabled', '1') === '1';
+
+$pageTitle = 'Digitaler Assistent';
+$activePage = 'assistant';
+require __DIR__ . '/_header.php';
+?>
+
+Digitaler Assistent
+
+= e($err) ?>
+
+ Die Datenbank ist noch nicht auf dem neuesten Stand. Bitte unter System-Update auf „Datenbank aktualisieren“ klicken.
+
+
+ 'Einstellungen', 'answers' => 'Zusätzliche Antworten' . ($answers ? ' (' . count($answers) . ')' : ''), 'log' => 'Fragen-Log'];
+?>
+
+
+
+
+
+
+
Einstellungen
+
+ Der Assistent beantwortet Fragen von Kunden aus der Wissensdatenbank und aus den Antworten unten. Findet er nichts, fragt er, ob er ein Ticket
+ erstellen soll, und erfragt dafür alles Nötige. Es wird kein externer KI-Dienst verwendet, Fragen bleiben auf diesem Server.
+
+
+ Zustand jetzt:
+ ausgeschaltet
+ aktiv (Kunden sehen den Assistenten)
+ bereit, aber gerade nicht sichtbar (Live-Chat ist besetzt= get_setting('helpdesk_status', 'online') === 'offline' ? ' bzw. das Supportcenter ist im Wartungsmodus' : '' ?>)
+
+
+
+
+
+
Frage ausprobieren
+
Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).
+
+ = csrf_field() ?>
+
+
+ Testen
+
+
+
+
= $testResult['source'] === 'custom' ? 'Zusätzliche Antwort' : 'Wissensdatenbank' ?>: = e($testResult['title']) ?>
+
= e($testResult['text']) ?>
+
Weiterer Treffer: = e($o['title']) ?>
+
+
+
Keine passende Antwort: Der Assistent würde anbieten, ein Ticket zu erstellen. Lege dafür unten eine Antwort an oder ergänze die Wissensdatenbank.
+
+
+
+
Gleichbedeutende Wörter
+
Wörter, die Kunden und Artikel unterschiedlich nennen, aber dasselbe meinen. Eine Gruppe je Zeile, durch Komma getrennt, zum Beispiel Zugang, Einladung, beitreten . Der Assistent behandelt sie dann gleich. Einige Gruppen sind schon eingebaut, deine ergänzen oder überschreiben sie.
+
+ = csrf_field() ?>
+
+ = e(get_setting('assistant_synonyms', '')) ?>
+ Speichern
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Fragen-Log
+ 0): ?>
+
+ = csrf_field() ?>
+ Log leeren
+
+
+
+
+
Das Log ist noch nicht verfügbar. Bitte unter System-Update auf „Datenbank aktualisieren“ klicken.
+
+
Hier siehst du, was Kunden den Assistenten gefragt haben und ob er eine Antwort gefunden hat. Es werden nur Frage und Ergebnis gespeichert (kein Name, keine E-Mail-Adresse). Fragen ohne Antwort zeigen, was du noch hinterlegen solltest.
+
+
+
+
+
+
+ Alle Ergebnisse
+ $label): ?>>= e($label) ?>
+
+
+ 'Heute', '7' => 'Letzte 7 Tage', '30' => 'Letzte 30 Tage', 'all' => 'Gesamter Zeitraum'] as $k => $l): ?>>= e($l) ?>
+
+
+ Filtern
+ Zurücksetzen
+
+
+
+
+
Häufige Fragen ohne Antwort
+
+
+
+
+
+
Keine Fragen für diese Auswahl.
+
+
+ Zeit Frage Ergebnis Antwort / Ticket
+
+
+
+ = e(date('d.m.Y H:i', strtotime((string) $r['created_at']))) ?>
+ = e($r['question']) ?>
+ = e($logResults[$r['result']] ?? $r['result']) ?>= $r['helpful'] === null ? '' : ($r['helpful'] ? ' 👍' : ' 👎') ?>
+ = $r['ticket_number'] ? e('#' . $r['ticket_number']) : e((string) ($r['matched_title'] ?? '')) ?>
+ Antwort anlegen
+
+
+
+
+
+
= (int) $logPage['total'] ?> Einträge
+ 1): ?>
+
+ 1): ?>← Neuere
+ Seite = (int) $logPage['page'] ?> von = (int) $logPage['pages'] ?>
+ Ältere →
+
+
+
+
+
+
+
+
+