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

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

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

+
+ + + + + +

Live-Chat ist derzeit (Einstellungen → Allgemein). Wissensdatenbank im Kundenportal: .

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

Die Tickets tragen den Tag „Assistent“ und durchlaufen deine Ticket-Regeln und die automatische Zuweisung der Abteilung. Wählt der Kunde ein Hilfethema, bestimmt dieses Abteilung und Priorität.

+
+
+
+ +
+

Frage ausprobieren

+

Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).

+
+ + + + +
+ +
+
:
+
+
Weiterer Treffer:
+
+ +
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.

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

Zusätzliche Antworten

+ + Antwort hinzufügen +
+

Diese Antworten gelten zusätzlich zur Wissensdatenbank und haben Vorrang. Der Assistent findet sie über die Wörter in der Frage und in den Stichwörtern.

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

Noch keine zusätzlichen Antworten.

+ + + + + + + + + + + + + + +
Frage / ThemaStichwörterGenutztStatus

×Aus' ?> + Bearbeiten · +
+ + +
+
+ +
+ + + + +
+
+

Fragen-Log

+ 0): ?> +
+ + +
+ +
+ +

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.

+ +
+ $label): ?> + +
+
+
+ +
+ +
+ + + + + + Zurücksetzen +
+ + +
+ Häufige Fragen ohne Antwort + +
+ + + +

Keine Fragen für diese Auswahl.

+ + + + + + + + + + + + + + +
ZeitFrageErgebnisAntwort / Ticket
Antwort anlegen
+
+ Einträge + 1): ?> + + 1): ?>← Neuere + Seite von + Ältere → + + +
+ + +
+ + +