Upload via GUI (290 Dateien)

This commit is contained in:
2026-10-23 17:22:19 +00:00
parent 234099d0e7
commit 14b74ef747
9 changed files with 720 additions and 2 deletions
+3 -2
View File
@@ -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) {
+9
View File
@@ -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 '<h2 style="margin:0 0 12px;font-size:20px;color:#0f172a;">Brauchen Sie noch Hilfe?</h2>'
. '<p style="margin:0 0 12px;color:#334155;font-size:15px;line-height:1.6;">Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{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.</p>'
. '{close_note}'
. '<p style="margin:20px 0 0;"><a href="{ticket_url}" style="background:#0f6f6a;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600;display:inline-block;">Ticket ansehen</a></p>';
}
function notify_customer_ticket_created(array $ticket): void
{
[$subject, $body] = render_email_template('ticket_created', [
@@ -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"
@@ -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'],
];
}
@@ -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
{
+204
View File
@@ -0,0 +1,204 @@
<?php
/**
* includes/ticket_extras.php
*
* Wiedervorlage (Ticket "auf später legen"), Beobachter (Mitarbeiter verfolgen ein Ticket, ohne es zugewiesen zu haben),
* ähnliche Tickets und Artikel sowie die Erinnerung an Kunden vor dem automatischen Schließen.
*/
// ── Wiedervorlage ────────────────────────────────────────────────────────────
/** Auswahl im Dialog "Wiedervorlage" (Schlüssel => 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<int,array{id:int,name:string}> */
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')));
}
+84
View File
@@ -0,0 +1,84 @@
<?php
/**
* includes/web_handoff.php
*
* Übergabe von der App an die Webseite: Für alles, was nur am Bildschirm sinnvoll zu bedienen ist (Einstellungen, Assistent,
* Backup, Plugins, Webhooks …), öffnen Android- und Windows-App die passende Seite der Weboberfläche. Die App holt dafür eine
* einmalige Anmelde-Adresse (60 Sekunden gültig, nur einmal benutzbar); die Webseite prüft dann wie immer selbst die Rechte.
* Die App zeigt nur die Seiten, die der Mitarbeiter laut dieser Liste überhaupt öffnen darf.
*/
/**
* Seiten der Weboberfläche, die die Apps nicht selbst nachbauen.
*
* @return array<int,array{key:string,label:string,path:string,group:string}>
*/
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];
}
+24
View File
@@ -0,0 +1,24 @@
<?php
/**
* staff/app_login.php: Einstiegspunkt für die Übergabe aus der Android- und Windows-App (siehe includes/web_handoff.php).
* Die Adresse enthält ein einmaliges, 60 Sekunden gültiges Token und leitet sofort auf die gewünschte Seite weiter.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/web_handoff.php';
header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
$pdo = get_db();
try {
$res = web_handoff_consume($pdo, (string) ($_GET['t'] ?? ''));
} catch (Throwable $e) {
$res = null;
}
if (!$res || !web_handoff_path_allowed($res['staff'], $res['path'])) {
flash('error', 'Dieser Link ist abgelaufen oder wurde schon benutzt. Bitte in der App erneut öffnen.');
redirect('/staff/login.php');
}
staff_login($res['staff']);
audit_log((int) $res['staff']['id'], 'Aus der App im Browser geöffnet: ' . strtok($res['path'], '?'));
redirect($res['path']);
+373
View File
@@ -0,0 +1,373 @@
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/assistant.php';
$admin = require_admin_or_permission('manage_assistant');
$pdo = get_db();
$errors = [];
$testQuestion = '';
$testResult = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'save_settings') {
$dept = (int) ($_POST['assistant_department_id'] ?? 0);
$prio = (int) ($_POST['assistant_priority_id'] ?? 0);
$mode = ($_POST['assistant_mode'] ?? 'offline_only') === 'always' ? 'always' : 'offline_only';
if ($dept > 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';
?>
<h1>Digitaler Assistent</h1>
<?php foreach ($errors as $err): ?><div class="flash error"><?= e($err) ?></div><?php endforeach; ?>
<?php if ($dbOutdated): ?>
<div class="flash warning">Die Datenbank ist noch nicht auf dem neuesten Stand. Bitte unter System-Update auf „Datenbank aktualisieren“ klicken.</div>
<?php endif; ?>
<?php
$tabs = ['settings' => 'Einstellungen', 'answers' => 'Zusätzliche Antworten' . ($answers ? ' (' . count($answers) . ')' : ''), 'log' => 'Fragen-Log'];
?>
<div style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border);margin-bottom:20px">
<?php foreach ($tabs as $key => $label): ?>
<a href="/staff/assistant.php?tab=<?= e($key) ?>" style="padding:9px 16px;text-decoration:none;color:inherit;border-bottom:2px solid <?= $tab === $key ? 'var(--primary,#2563eb)' : 'transparent' ?>;font-weight:<?= $tab === $key ? '600' : '400' ?>;margin-bottom:-1px"><?= e($label) ?></a>
<?php endforeach; ?>
</div>
<?php if ($tab === 'settings'): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(380px,1fr));gap:20px;align-items:start">
<div class="card">
<h3>Einstellungen</h3>
<p class="muted" style="font-size:.85rem;margin-bottom:12px">
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.
</p>
<p style="margin:0 0 12px;font-size:.88rem">
Zustand jetzt:
<?php if (!$enabled): ?><strong style="color:#b45309">ausgeschaltet</strong>
<?php elseif ($activeNow): ?><strong style="color:#15803d">aktiv</strong> <span class="muted">(Kunden sehen den Assistenten)</span>
<?php else: ?><strong style="color:#64748b">bereit, aber gerade nicht sichtbar</strong> <span class="muted">(Live-Chat ist besetzt<?= get_setting('helpdesk_status', 'online') === 'offline' ? ' bzw. das Supportcenter ist im Wartungsmodus' : '' ?>)</span><?php endif; ?>
</p>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_settings">
<label style="display:flex;align-items:center;gap:8px;margin:0"><input type="checkbox" name="assistant_enabled" value="1" style="width:auto" <?= $enabled ? 'checked' : '' ?>> Digitalen Assistenten einschalten</label>
<label for="assistant_mode" style="margin-top:12px">Wann ist er aktiv?</label>
<select id="assistant_mode" name="assistant_mode">
<option value="offline_only" <?= get_setting('assistant_mode', 'offline_only') !== 'always' ? 'selected' : '' ?>>Nur wenn der Live-Chat ausgeschaltet oder niemand erreichbar ist (Standard)</option>
<option value="always" <?= get_setting('assistant_mode', 'offline_only') === 'always' ? 'selected' : '' ?>>Immer (zum Testen)</option>
</select>
<p class="muted" style="font-size:.78rem;margin-top:4px">Live-Chat ist derzeit <strong><?= $liveChatOn ? 'eingeschaltet' : 'ausgeschaltet' ?></strong> (Einstellungen → Allgemein). Wissensdatenbank im Kundenportal: <strong><?= $kbOn ? 'an' : 'aus (dann nutzt der Assistent nur die Antworten unten)' ?></strong>.</p>
<label for="assistant_name" style="margin-top:12px">Name des Assistenten</label>
<input type="text" id="assistant_name" name="assistant_name" maxlength="60" value="<?= e(get_setting('assistant_name', '')) ?>" placeholder="Digitaler Assistent">
<label for="assistant_greeting" style="margin-top:12px">Begrüßung</label>
<textarea id="assistant_greeting" name="assistant_greeting" rows="3" maxlength="500" placeholder="<?= e($texts['greeting']) ?>"><?= e(get_setting('assistant_greeting', '')) ?></textarea>
<label for="assistant_no_answer" style="margin-top:12px">Text, wenn er keine Antwort findet</label>
<textarea id="assistant_no_answer" name="assistant_no_answer" rows="2" maxlength="500" placeholder="<?= e($texts['no_answer']) ?>"><?= e(get_setting('assistant_no_answer', '')) ?></textarea>
<label for="assistant_department_id" style="margin-top:12px">Abteilung für Tickets des Assistenten</label>
<select id="assistant_department_id" name="assistant_department_id">
<option value="0">— Standardabteilung (bzw. Abteilung des gewählten Themas) —</option>
<?php foreach ($departments as $d): ?><option value="<?= (int) $d['id'] ?>" <?= (int) get_setting('assistant_department_id', '0') === (int) $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option><?php endforeach; ?>
</select>
<label for="assistant_priority_id" style="margin-top:12px">Priorität für Tickets des Assistenten</label>
<select id="assistant_priority_id" name="assistant_priority_id">
<option value="0">— Standard —</option>
<?php foreach ($priorities as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) get_setting('assistant_priority_id', '0') === (int) $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?>
</select>
<label for="assistant_log_days" style="margin-top:12px">Fragen-Log: Aufbewahrung in Tagen (0 = nichts speichern)</label>
<input type="number" id="assistant_log_days" name="assistant_log_days" min="0" max="3650" value="<?= (int) assistant_log_days() ?>" style="max-width:140px">
<p class="muted" style="font-size:.78rem;margin-top:4px">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.</p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</div>
<div class="card">
<h3>Frage ausprobieren</h3>
<p class="muted" style="font-size:.85rem;margin-bottom:10px">Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).</p>
<form method="post" style="display:flex;gap:8px;flex-wrap:wrap">
<?= csrf_field() ?>
<input type="hidden" name="action" value="test">
<input type="text" name="question" value="<?= e($testQuestion) ?>" placeholder="z. B. Wie setze ich mein Passwort zurück?" maxlength="300" style="flex:1;min-width:220px">
<button type="submit" class="btn secondary">Testen</button>
</form>
<?php if ($testResult): ?>
<div style="margin-top:12px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">
<div class="muted" style="font-size:.76rem"><?= $testResult['source'] === 'custom' ? 'Zusätzliche Antwort' : 'Wissensdatenbank' ?>: <?= e($testResult['title']) ?></div>
<div style="white-space:pre-wrap;margin-top:6px;font-size:.88rem"><?= e($testResult['text']) ?></div>
<?php foreach ($testResult['others'] as $o): ?><div class="muted" style="font-size:.78rem;margin-top:4px">Weiterer Treffer: <?= e($o['title']) ?></div><?php endforeach; ?>
</div>
<?php elseif ($testResult === false): ?>
<div style="margin-top:12px" class="muted">Keine passende Antwort: Der Assistent würde anbieten, ein Ticket zu erstellen. Lege dafür unten eine Antwort an oder ergänze die Wissensdatenbank.</div>
<?php endif; ?>
</div>
<div class="card">
<h3>Gleichbedeutende Wörter</h3>
<p class="muted" style="font-size:.85rem;margin-bottom:10px">Wörter, die Kunden und Artikel unterschiedlich nennen, aber dasselbe meinen. Eine Gruppe je Zeile, durch Komma getrennt, zum Beispiel <em>Zugang, Einladung, beitreten</em>. Der Assistent behandelt sie dann gleich. Einige Gruppen sind schon eingebaut, deine ergänzen oder überschreiben sie.</p>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_synonyms">
<textarea name="assistant_synonyms" rows="6" maxlength="5000" placeholder="Zugang, Einladung, beitreten&#10;Geld zurück, Erstattung, Rückzahlung"><?= e(get_setting('assistant_synonyms', '')) ?></textarea>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</div>
</div>
<?php endif; ?>
<?php if ($tab === 'answers'): ?>
<div class="card">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<h3 style="margin:0">Zusätzliche Antworten</h3>
<?php if (!$showForm): ?><a href="/staff/assistant.php?new=1" class="btn" style="margin-left:auto">+ Antwort hinzufügen</a><?php endif; ?>
</div>
<p class="muted" style="font-size:.85rem;margin-bottom:12px">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.</p>
<?php if ($showForm && !$dbOutdated): ?>
<form method="post" id="antwort-form" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_answer">
<input type="hidden" name="id" value="<?= (int) ($edit['id'] ?? ($_POST['id'] ?? 0)) ?>">
<label for="title">Frage / Thema *</label>
<input type="text" id="title" name="title" maxlength="255" required value="<?= e($_POST['title'] ?? ($edit['title'] ?? $prefillTitle)) ?>" placeholder="z. B. Wie lange dauert der Versand?">
<label for="keywords" style="margin-top:10px">Stichwörter (optional, durch Komma getrennt)</label>
<input type="text" id="keywords" name="keywords" maxlength="1000" value="<?= e($_POST['keywords'] ?? ($edit['keywords'] ?? '')) ?>" placeholder="z. B. Lieferzeit, Paket, Lieferung, Sendung">
<label for="answer" style="margin-top:10px">Antwort *</label>
<textarea id="answer" name="answer" rows="5" maxlength="4000" required placeholder="Reiner Text. Adressen mit https:// werden anklickbar."><?= e($_POST['answer'] ?? ($edit['answer'] ?? '')) ?></textarea>
<label style="display:flex;align-items:center;gap:8px;margin-top:10px"><input type="checkbox" name="is_active" value="1" style="width:auto" <?= (!$edit && empty($_POST)) || !empty($edit['is_active']) || !empty($_POST['is_active']) ? 'checked' : '' ?>> Aktiv</label>
<div class="btn-row" style="gap:8px"><button type="submit" class="btn">Speichern</button><a href="/staff/assistant.php?tab=answers" class="btn secondary">Abbrechen</a></div>
</form>
<?php endif; ?>
<?php if (!$answers): ?>
<p class="muted">Noch keine zusätzlichen Antworten.</p>
<?php else: ?>
<table>
<thead><tr><th>Frage / Thema</th><th>Stichwörter</th><th>Genutzt</th><th>Status</th><th></th></tr></thead>
<tbody>
<?php foreach ($answers as $a): ?>
<tr>
<td><a href="/staff/assistant.php?edit=<?= (int) $a['id'] ?>"><?= e($a['title']) ?></a><br><span class="muted" style="font-size:.78rem"><?= e(mb_strimwidth((string) $a['answer'], 0, 90, '…')) ?></span></td>
<td class="muted"><?= e((string) ($a['keywords'] ?? '')) ?></td>
<td><?= (int) $a['hits'] ?>×</td>
<td><?= $a['is_active'] ? 'Aktiv' : '<span class="muted">Aus</span>' ?></td>
<td style="white-space:nowrap">
<a href="/staff/assistant.php?edit=<?= (int) $a['id'] ?>">Bearbeiten</a> ·
<form method="post" style="display:inline" onsubmit="return confirm('Diese Antwort löschen?')">
<?= csrf_field() ?><input type="hidden" name="action" value="delete_answer"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
<button type="submit" class="link" style="background:none;border:0;color:var(--danger);cursor:pointer;padding:0">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($tab === 'log'): ?>
<div class="card" id="fragen-log">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px">
<h3 style="margin:0">Fragen-Log</h3>
<?php if (!$logOutdated && array_sum($logStats) > 0): ?>
<form method="post" style="margin-left:auto" onsubmit="return confirm('Das gesamte Fragen-Log löschen?')">
<?= csrf_field() ?><input type="hidden" name="action" value="clear_log">
<button type="submit" class="btn secondary">Log leeren</button>
</form>
<?php endif; ?>
</div>
<?php if ($logOutdated): ?>
<p class="muted">Das Log ist noch nicht verfügbar. Bitte unter System-Update auf „Datenbank aktualisieren“ klicken.</p>
<?php else: ?>
<p class="muted" style="font-size:.85rem;margin-bottom:12px">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.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px">
<?php foreach ($logResults as $key => $label): ?>
<a href="<?= e($logQuery(['lr' => $logFilter['result'] === $key ? '' : $key, 'lpage' => ''])) ?>" style="text-decoration:none;color:inherit;padding:8px 12px;border:1px solid <?= $logFilter['result'] === $key ? 'var(--primary,#2563eb)' : 'var(--border)' ?>;border-radius:8px;min-width:120px">
<div style="font-size:1.2rem;font-weight:600"><?= (int) $logStats[$key] ?></div>
<div class="muted" style="font-size:.76rem"><?= e($label) ?></div>
</a>
<?php endforeach; ?>
</div>
<form method="get" action="/staff/assistant.php" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
<input type="hidden" name="tab" value="log">
<select name="lr" style="width:auto">
<option value="">Alle Ergebnisse</option>
<?php foreach ($logResults as $key => $label): ?><option value="<?= e($key) ?>" <?= $logFilter['result'] === $key ? 'selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?>
</select>
<select name="lp" style="width:auto">
<?php foreach (['today' => 'Heute', '7' => 'Letzte 7 Tage', '30' => 'Letzte 30 Tage', 'all' => 'Gesamter Zeitraum'] as $k => $l): ?><option value="<?= e($k) ?>" <?= $logFilter['period'] === $k ? 'selected' : '' ?>><?= e($l) ?></option><?php endforeach; ?>
</select>
<input type="text" name="lq" value="<?= e($logFilter['q']) ?>" placeholder="Suchen…" maxlength="100" style="flex:1;min-width:160px">
<button type="submit" class="btn secondary">Filtern</button>
<?php if ($logFilter['result'] !== '' || $logFilter['q'] !== '' || $logFilter['period'] !== '30'): ?><a href="/staff/assistant.php?tab=log" class="btn secondary">Zurücksetzen</a><?php endif; ?>
</form>
<?php if ($logTop): ?>
<div style="margin-bottom:16px;padding:10px 12px;border:1px solid var(--border);border-radius:8px">
<strong style="font-size:.88rem">Häufige Fragen ohne Antwort</strong>
<ul style="margin:6px 0 0;padding-left:18px;font-size:.88rem">
<?php foreach ($logTop as $t): ?>
<li><?= e($t['question']) ?> <span class="muted">(<?= (int) $t['n'] ?>×)</span> · <a href="/staff/assistant.php?new=1&amp;title=<?= e(rawurlencode($t['question'])) ?>">Antwort dazu anlegen</a></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (!$logPage['rows']): ?>
<p class="muted">Keine Fragen für diese Auswahl.</p>
<?php else: ?>
<table>
<thead><tr><th>Zeit</th><th>Frage</th><th>Ergebnis</th><th>Antwort / Ticket</th><th></th></tr></thead>
<tbody>
<?php foreach ($logPage['rows'] as $r): ?>
<tr>
<td class="muted" style="white-space:nowrap"><?= e(date('d.m.Y H:i', strtotime((string) $r['created_at']))) ?></td>
<td><?= e($r['question']) ?></td>
<td><?= e($logResults[$r['result']] ?? $r['result']) ?><?= $r['helpful'] === null ? '' : ($r['helpful'] ? ' 👍' : ' 👎') ?></td>
<td class="muted"><?= $r['ticket_number'] ? e('#' . $r['ticket_number']) : e((string) ($r['matched_title'] ?? '')) ?></td>
<td style="white-space:nowrap"><?php if ($r['result'] === 'none'): ?><a href="/staff/assistant.php?new=1&amp;title=<?= e(rawurlencode($r['question'])) ?>">Antwort anlegen</a><?php endif; ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="muted" style="display:flex;gap:12px;align-items:center;margin-top:10px;font-size:.85rem">
<span><?= (int) $logPage['total'] ?> Einträge</span>
<?php if ($logPage['pages'] > 1): ?>
<span style="margin-left:auto">
<?php if ($logPage['page'] > 1): ?><a href="<?= e($logQuery(['lpage' => $logPage['page'] - 1])) ?>">← Neuere</a><?php endif; ?>
Seite <?= (int) $logPage['page'] ?> von <?= (int) $logPage['pages'] ?>
<?php if ($logPage['page'] < $logPage['pages']): ?><a href="<?= e($logQuery(['lpage' => $logPage['page'] + 1])) ?>">Ältere →</a><?php endif; ?>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>