Files
deskhub/DeskHub-Webseite/staff/tickets.php
T
2026-11-04 07:55:30 +00:00

743 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/ticket_extras.php'; // falls bootstrap.php noch die alte Version ist
$staff = require_staff_login();
$pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
$departments = $pdo->query('SELECT * FROM departments ORDER BY name')->fetchAll();
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$bulkErrors = [];
$resolutionReasons = ticket_resolution_reasons();
// ---------------------------------------------------------------
// E-Mail-Abruf von Hand starten
// ---------------------------------------------------------------
require_once __DIR__ . '/../includes/mail_fetch_manual.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'fetch_mail_now') {
verify_csrf();
$fetch = mail_fetch_manual($pdo);
flash($fetch['level'], $fetch['message']);
audit_log((int) $staff['id'], 'E-Mail-Abruf von Hand gestartet (' . $fetch['processed'] . ' E-Mail(s))');
redirect('/staff/tickets.php' . (($_POST['return_query'] ?? '') !== '' ? '?' . ltrim((string) $_POST['return_query'], '?') : ''));
}
$fetchAvailable = mail_fetch_available($pdo);
$fetchLastRun = (string) get_setting('cron_fetch_emails_last_run', '');
// ---------------------------------------------------------------
// Gespeicherte Ansichten: speichern / löschen
// ---------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && in_array($_POST['queue_action'] ?? '', ['save', 'delete'], true)) {
verify_csrf();
$back = '/staff/tickets.php' . (($_POST['return_query'] ?? '') !== '' ? '?' . ltrim((string) $_POST['return_query'], '?') : '');
try {
if ($_POST['queue_action'] === 'save') {
parse_str((string) ($_POST['return_query'] ?? ''), $currentGet);
queue_create($pdo, (int) $staff['id'], (string) ($_POST['queue_name'] ?? ''), queue_filters_from_web($currentGet), !empty($_POST['queue_shared']));
flash('success', 'Ansicht gespeichert.');
} else {
$qid = (int) ($_POST['queue_id'] ?? 0);
$row = $pdo->prepare('SELECT * FROM saved_queues WHERE id = ?');
$row->execute([$qid]);
$row = $row->fetch();
if ($row && queue_can_edit($row, $staff)) {
$pdo->prepare('DELETE FROM saved_queues WHERE id = ?')->execute([$qid]);
flash('success', 'Ansicht gelöscht.');
}
$back = '/staff/tickets.php';
}
} catch (AdminModelException $e) {
flash('error', $e->getMessage());
}
redirect($back);
}
// ---------------------------------------------------------------
// Sammelaktionen
// ---------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['bulk_action'] ?? '') !== '') {
verify_csrf();
$ticketIds = array_filter(array_map('intval', $_POST['ticket_ids'] ?? []));
$bulkAction = $_POST['bulk_action'];
if (!$ticketIds) {
$bulkErrors[] = 'Bitte wählen Sie mindestens ein Ticket aus.';
} else {
// Zugriff prüfen: nur Tickets aus erlaubten Abteilungen dürfen verändert werden
$placeholders = implode(',', array_fill(0, count($ticketIds), '?'));
[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 'department_id');
$stmt = $pdo->prepare("SELECT * FROM tickets WHERE id IN ({$placeholders})" . $deptFilter);
$stmt->execute(array_merge($ticketIds, $deptParams));
$allowedTickets = $stmt->fetchAll();
if (count($allowedTickets) < count($ticketIds)) {
$bulkErrors[] = 'Einige ausgewählte Tickets gehören zu Abteilungen, auf die Sie keinen Zugriff haben, und wurden übersprungen.';
}
$bulkResolutionCode = (string) ($_POST['bulk_resolution_code'] ?? '');
$bulkResolutionNote = trim(mb_substr((string) ($_POST['bulk_resolution_note'] ?? ''), 0, 2000));
if ($bulkAction === 'status' && in_array((string) ($_POST['bulk_status'] ?? ''), ['resolved', 'closed'], true) && !isset($resolutionReasons[$bulkResolutionCode])) {
$bulkErrors[] = 'Bitte für das Schließen einen Abschlussgrund auswählen.';
$allowedTickets = [];
}
$now = db_now();
// Abteilung wechseln: Ziel prüfen (muss existieren und für den Mitarbeiter erreichbar sein), Rechte je Ticket später
$moveTarget = null;
$movedCount = 0;
$movedSkipped = ['same' => 0, 'perm' => 0];
if ($bulkAction === 'department') {
$newDeptId = (int) ($_POST['bulk_department_id'] ?? 0);
$targetStmt = $pdo->prepare('SELECT id, name FROM departments WHERE id = ?');
$targetStmt->execute([$newDeptId]);
$moveTarget = $targetStmt->fetch() ?: null;
if (!$moveTarget) {
$bulkErrors[] = 'Bitte eine Ziel-Abteilung wählen.';
$allowedTickets = [];
} elseif ($myDeptIds !== null && !in_array($newDeptId, $myDeptIds, true)) {
$bulkErrors[] = 'Sie haben keinen Zugriff auf die Abteilung „' . $moveTarget['name'] . '“.';
$allowedTickets = [];
}
}
foreach ($allowedTickets as $t) {
if ($bulkAction === 'department' && $moveTarget) {
if ((int) $t['department_id'] === (int) $moveTarget['id']) { $movedSkipped['same']++; continue; }
if (!has_permission($staff, 'transfer_ticket', (int) $t['department_id'])) { $movedSkipped['perm']++; continue; }
// SLA-Fristen nach den Regeln der neuen Abteilung neu berechnen (wie beim Ändern in der Ticketansicht)
$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$prioStmt->execute([$t['priority_id']]);
$prioRow = $prioStmt->fetch();
$sla = $prioRow ? calc_sla_dates($prioRow, $t['created_at'], (int) $moveTarget['id'], !empty($t['help_topic_id']) ? (int) $t['help_topic_id'] : null) : null;
if ($sla) {
$priorPauses = ticket_sla_completed_pause_seconds($pdo, (int) $t['id']);
foreach (['first_response_due_at', 'resolution_due_at'] as $deadline) if (!empty($sla[$deadline]) && $priorPauses > 0) $sla[$deadline] = ticket_sla_shift_deadline($sla[$deadline], $priorPauses);
if (!empty($sla['first_response_due_at'])) ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'first_response', 'Neue Frist nach Abteilungswechsel: ' . $sla['first_response_due_at'], (int) $staff['id']);
if (!empty($sla['resolution_due_at'])) ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'resolution', 'Neue Frist nach Abteilungswechsel: ' . $sla['resolution_due_at'], (int) $staff['id']);
}
$pdo->prepare('UPDATE tickets SET department_id=?, first_response_due_at=?, resolution_due_at=?, updated_at=? WHERE id=?')
->execute([(int) $moveTarget['id'], $sla['first_response_due_at'] ?? $t['first_response_due_at'], $sla['resolution_due_at'] ?? $t['resolution_due_at'], $now, $t['id']]);
$oldDeptName = '';
foreach ($departments as $d0) { if ((int) $d0['id'] === (int) $t['department_id']) $oldDeptName = $d0['name']; }
log_activity($pdo, $t['id'], $staff['id'], 'Abteilung geändert (Sammelaktion): ' . $oldDeptName . ' → ' . $moveTarget['name']);
$movedCount++;
continue;
}
if ($bulkAction === 'assign') {
$newAgent = $_POST['bulk_assigned_staff_id'] !== '' ? (int) $_POST['bulk_assigned_staff_id'] : null;
$pdo->prepare('UPDATE tickets SET assigned_staff_id=?, updated_at=? WHERE id=?')->execute([$newAgent, $now, $t['id']]);
if ($newAgent !== null && $newAgent !== (int) ($t['assigned_staff_id'] ?? 0)) fcm_notify_assignment($t, $newAgent, (int) $staff['id']); // Push auf die Android-App
log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Mitarbeiter zugewiesen');
} elseif ($bulkAction === 'priority') {
$newPrio = (int) $_POST['bulk_priority_id'];
$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$prioStmt->execute([$newPrio]); $newPriority = $prioStmt->fetch();
$sla = $newPriority ? calc_sla_dates($newPriority, $t['created_at'], (int) $t['department_id'], !empty($t['help_topic_id']) ? (int) $t['help_topic_id'] : null) : null;
if ($sla) {
$priorPauses = ticket_sla_completed_pause_seconds($pdo, (int) $t['id']);
foreach (['first_response_due_at', 'resolution_due_at'] as $deadline) if (!empty($sla[$deadline]) && $priorPauses > 0) $sla[$deadline] = ticket_sla_shift_deadline($sla[$deadline], $priorPauses);
ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'first_response', 'Neue Frist nach Prioritaetswechsel: ' . $sla['first_response_due_at'], (int) $staff['id']);
ticket_sla_history_add($pdo, (int) $t['id'], 'adjusted', 'resolution', 'Neue Frist nach Prioritaetswechsel: ' . $sla['resolution_due_at'], (int) $staff['id']);
}
$pdo->prepare('UPDATE tickets SET priority_id=?, first_response_due_at=?, resolution_due_at=?, updated_at=? WHERE id=?')
->execute([$newPrio, $sla['first_response_due_at'] ?? $t['first_response_due_at'], $sla['resolution_due_at'] ?? $t['resolution_due_at'], $now, $t['id']]);
log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Priorität geändert');
} elseif ($bulkAction === 'status') {
$newStatus = $_POST['bulk_status'];
$resolvedAt = $newStatus === 'resolved' ? $now : $t['resolved_at'];
$closedAt = $newStatus === 'closed' ? $now : $t['closed_at'];
$pdo->prepare('UPDATE tickets SET status=?, resolved_at=?, closed_at=?, resolution_code=?, resolution_note=?, updated_at=? WHERE id=?')
->execute([$newStatus, $resolvedAt, $closedAt, in_array($newStatus, ['resolved', 'closed'], true) ? $bulkResolutionCode : $t['resolution_code'], in_array($newStatus, ['resolved', 'closed'], true) ? ($bulkResolutionNote ?: $t['resolution_note']) : $t['resolution_note'], $now, $t['id']]);
ticket_sla_status_transition($pdo, (int) $t['id'], (string) $t['status'], (string) $newStatus, (int) $staff['id']);
log_activity($pdo, $t['id'], $staff['id'], 'Sammelaktion: Status geändert');
} elseif ($bulkAction === 'delete') {
delete_ticket($pdo, $t, $staff['id']);
} elseif ($bulkAction === 'ban') {
$email = $t['requester_email'];
$domain = $email ? substr($email, strpos($email,'@')+1) : '';
if ($email) {
$priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE requester_email=? AND status NOT IN ('closed')");
$priorStmt->execute([$email]); $priorTickets = $priorStmt->fetchAll();
$chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
$chk->execute([$email]);
if (!$chk->fetchColumn()) {
$pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
->execute(['email', $email, 'Bulk-Sperre', $staff['id'], $now]);
}
// Alle Tickets dieses Absenders schließen
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Absender im Rahmen einer Sammelaktion gesperrt',closed_at=?,updated_at=? WHERE requester_email=? AND status NOT IN ('closed')")
->execute([$now, $now, $email]);
ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']);
}
}
}
if (!$bulkErrors && $bulkAction === 'department') {
$msg = $movedCount . ' Ticket(s) in die Abteilung „' . $moveTarget['name'] . '“ verschoben.';
if ($movedSkipped['same']) $msg .= ' ' . $movedSkipped['same'] . ' waren schon dort.';
if ($movedSkipped['perm']) $msg .= ' ' . $movedSkipped['perm'] . ' übersprungen (keine Berechtigung zum Verschieben).';
flash($movedCount > 0 ? 'success' : 'error', $msg);
} elseif (!$bulkErrors) {
$verb = ['assign' => 'zugewiesen', 'priority' => 'Priorität geändert', 'status' => 'Status geändert', 'delete' => 'gelöscht'][$bulkAction] ?? 'aktualisiert';
flash('success', count($allowedTickets) . ' Ticket(s) ' . $verb . '.');
} else {
foreach ($bulkErrors as $e) flash('error', $e);
}
redirect('/staff/tickets.php?' . http_build_query($_GET));
}
}
$filterDept = $_GET['department_id'] ?? '';
$filterStatus = $_GET['status'] ?? '';
$filterPrio = $_GET['priority_id'] ?? '';
$filterAssign = $_GET['assigned'] ?? '';
$filterTag = $_GET['tag_id'] ?? '';
$filterSpam = $_GET['spam'] ?? '';
$filterSla = $_GET['sla'] ?? '';
$search = trim($_GET['q'] ?? '');
$activeQuick = $_GET['quick'] ?? ''; // 'me', 'unassigned', 'overdue', 'highprio'
$where = [];
$params = [];
// Spam-Tickets sind standardmäßig aus der normalen Ticketliste ausgeblendet.
if ($filterSpam === '1') {
$where[] = 't.is_spam = 1';
} else {
$where[] = 't.is_spam = 0';
}
[$deptFilter, $deptParams] = dept_filter_sql($myDeptIds, 't.department_id');
if ($deptFilter !== '') { $where[] = substr($deptFilter, 5); array_push($params, ...$deptParams); }
// Schnellfilter-Tabs
if ($activeQuick === 'me') {
$where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id'];
$where[] = "t.status NOT IN ('resolved','closed')";
} elseif ($activeQuick === 'unassigned') {
$where[] = 't.assigned_staff_id IS NULL';
$where[] = "t.status NOT IN ('resolved','closed')";
} elseif ($activeQuick === 'overdue') {
$where[] = '(t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at < ?))'; $params[] = db_now();
$where[] = "t.status NOT IN ('resolved','closed')";
} elseif ($activeQuick === 'highprio') {
$where[] = 't.priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)';
$where[] = "t.status NOT IN ('resolved','closed')";
}
if ($filterDept !== '') { $where[] = 't.department_id = ?'; $params[] = $filterDept; }
if ($filterStatus !== '' && !isset($_GET['tab'])) { $where[] = 't.status = ?'; $params[] = $filterStatus; }
if ($filterPrio !== '') { $where[] = 't.priority_id = ?'; $params[] = $filterPrio; }
if ($filterAssign === 'me') { $where[] = 't.assigned_staff_id = ?'; $params[] = $staff['id']; }
elseif ($filterAssign === 'unassigned') { $where[] = 't.assigned_staff_id IS NULL'; }
elseif ($filterAssign !== '') { $where[] = 't.assigned_staff_id = ?'; $params[] = $filterAssign; }
if ($filterTag !== '') {
$where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)';
$params[] = $filterTag;
}
// SLA-Status: "at_risk" = Lösungsfrist läuft innerhalb der nächsten 4 Stunden ab, aber
// noch nicht überschritten. "overdue"/"on_time" ergänzen sich mit is_overdue (siehe
// cron/check_sla.php), berücksichtigen aber auch Tickets, deren Frist gerade erst
// (zwischen zwei Cron-Läufen) verstrichen ist, statt sich blind auf das Flag zu verlassen.
if ($filterSla !== '') {
$now = db_now();
$riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
if ($filterSla === 'overdue') {
$where[] = "t.resolution_due_at IS NOT NULL AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at < ?)) AND t.status NOT IN ('resolved','closed')";
$params[] = $now;
} elseif ($filterSla === 'at_risk') {
$where[] = "t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
array_push($params, $now, $riskThreshold);
} elseif ($filterSla === 'on_time') {
$where[] = "t.sla_paused_at IS NULL AND (t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
$params[] = $riskThreshold;
}
}
if ($search !== '') {
$like = '%' . $search . '%';
if (!empty($_GET['fulltext'])) {
$where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? OR t.requester_name LIKE ?
OR t.id IN (SELECT ticket_id FROM ticket_threads WHERE message LIKE ?))';
array_push($params, $like, $like, $like, $like, $like);
} else {
$where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ? OR t.requester_name LIKE ?)';
array_push($params, $like, $like, $like, $like);
}
}
$activeTab = $_GET['tab'] ?? 'open';
// Bedingung: Ticket ist von gesperrtem Absender
$notBannedSql = "(t.requester_email NOT IN (SELECT value FROM banlist WHERE match_type='email')
AND " . sql_email_domain("t.requester_email") . " NOT IN (SELECT value FROM banlist WHERE match_type='domain'))";
if ($activeTab === 'banned') {
// Nur gesperrte Absender zeigen
$where[] = "(t.requester_email IN (SELECT value FROM banlist WHERE match_type='email')
OR " . sql_email_domain("t.requester_email") . " IN (SELECT value FROM banlist WHERE match_type='domain'))";
} else {
// Gesperrte Absender in ALLEN anderen Tabs ausblenden
$where[] = $notBannedSql;
// Tickets in der Wiedervorlage erscheinen nur im eigenen Tab, bis ihre Zeit gekommen ist
$where[] = $activeTab === 'snoozed' ? (ticket_snooze_available() ? "t.snoozed_until > '" . db_now() . "'" : '1=0') : ticket_not_snoozed_sql('t');
if ($activeTab === 'open') {
$where[] = "t.status IN ('open','pending','answered')";
} elseif ($activeTab === 'snoozed') {
// alle Status
} elseif ($activeTab === 'on_hold') {
$where[] = "t.status = 'on_hold'";
} elseif ($activeTab === 'resolved') {
$where[] = "t.status = 'resolved'";
} elseif ($activeTab === 'closed') {
$where[] = "t.status = 'closed'";
} elseif ($activeTab === 'all') {
// kein weiterer Filter
} else {
$where[] = "t.status IN ('open','pending','answered')";
}
}
$sql = "SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id";
if ($where) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
// Gesamtanzahl
$countSql = "SELECT COUNT(*) FROM tickets t JOIN priorities p ON p.id = t.priority_id JOIN departments d ON d.id = t.department_id LEFT JOIN staff s ON s.id = t.assigned_staff_id"
. ($where ? ' WHERE ' . implode(' AND ', $where) : '');
$countStmt = $pdo->prepare($countSql);
$countStmt->execute($params);
$totalCount = (int)$countStmt->fetchColumn();
$totalPages = max(1, (int)ceil($totalCount / $perPage));
$sql .= ' ORDER BY t.updated_at DESC, t.created_at DESC LIMIT ' . $perPage . ' OFFSET ' . $offset;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$tickets = $stmt->fetchAll();
// Tags je angezeigtem Ticket nachladen (eine Abfrage für alle sichtbaren Tickets)
$ticketTags = [];
if ($tickets) {
$ids = array_column($tickets, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$tagStmt = $pdo->prepare(
"SELECT tt.ticket_id, tg.id, tg.name, tg.color FROM ticket_tags tt
JOIN tags tg ON tg.id = tt.tag_id WHERE tt.ticket_id IN ({$placeholders})"
);
$tagStmt->execute($ids);
foreach ($tagStmt->fetchAll() as $row) {
$ticketTags[$row['ticket_id']][] = $row;
}
}
$allTags = $pdo->query('SELECT * FROM tags ORDER BY name')->fetchAll();
// Sichtbare Abteilungen für das Filter-Dropdown auf Zugriffsrechte beschränken
$visibleDepartments = $myDeptIds === null ? $departments : array_filter($departments, fn($d) => in_array((int) $d['id'], $myDeptIds, true));
// Schnellfilter-Zähler
$_stMe = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id=? AND status NOT IN ('resolved','closed')");
$_stMe->execute([$staff['id']]);
$countMe = (int)$_stMe->fetchColumn();
$countUnassigned= (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')")->fetchColumn();
$_stOd = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE (is_overdue = 1 OR (sla_paused_at IS NULL AND resolution_due_at < ?)) AND status NOT IN ('resolved','closed')");
$_stOd->execute([db_now()]);
$countOverdue = (int)$_stOd->fetchColumn();
$countHighPrio = (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND priority_id IN (SELECT id FROM (SELECT id FROM priorities ORDER BY sort_order DESC LIMIT 2) AS top_priorities)")->fetchColumn();
// Live-Refresh der Ticketliste ohne Seiten-Reload (siehe <script> weiter unten): liefert
// bei ?partial=1 nur die Tabelle/Pagination aus derselben Query-Logik wie oben, damit
// Filter/Seite/Sortierung 1:1 übereinstimmen und nichts doppelt gepflegt werden muss.
if (($_GET['partial'] ?? '') === '1') {
require __DIR__ . '/_tickets_list_partial.php';
exit;
}
$pageTitle = 'Tickets';
$activePage = 'tickets';
require __DIR__ . '/_header.php';
?>
<?php
// Ticket-Zähler für Tabs
$tabCounts = [];
foreach (['open'=>"status IN ('open','pending','answered')", 'on_hold'=>"status='on_hold'", 'resolved'=>"status='resolved'", 'closed'=>"status='closed'"] as $tk => $tcond) {
$tabCounts[$tk] = (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE $tcond AND " . ticket_not_snoozed_sql('tickets'))->fetchColumn();
}
$tabCounts['snoozed'] = ticket_snooze_available() ? (int)$pdo->query("SELECT COUNT(*) FROM tickets WHERE snoozed_until > '" . db_now() . "'")->fetchColumn() : 0;
// Gesperrt-Tab: sicher mit try/catch
try {
$tabCounts['banned'] = (int)$pdo->query(
"SELECT COUNT(*) FROM tickets WHERE requester_email IN (SELECT value FROM banlist WHERE match_type='email')
OR " . sql_email_domain("requester_email") . " IN (SELECT value FROM banlist WHERE match_type='domain')"
)->fetchColumn();
} catch (Exception $e) { $tabCounts['banned'] = 0; }
?>
<div class="topbar">
<h1>Tickets</h1>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<?php if ($fetchAvailable): ?>
<form method="post" style="display:inline" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='⏳ Abruf läuft …';">
<?= csrf_field() ?>
<input type="hidden" name="action" value="fetch_mail_now">
<input type="hidden" name="return_query" value="<?= e((string) ($_SERVER['QUERY_STRING'] ?? '')) ?>">
<button type="submit" class="btn secondary" title="<?= $fetchLastRun !== '' && $fetchLastRun > '1971' ? 'Letzter Abruf: ' . e(format_dt($fetchLastRun)) : 'Noch kein Abruf' ?>">📥 E-Mails jetzt abrufen</button>
</form>
<?php endif; ?>
<a href="/staff/ticket_create.php" class="btn">+ Ticket erstellen</a>
</div>
</div>
<!-- Tabs + Schnellfilter-Dropdown -->
<?php
$activeQuick = $_GET['quick'] ?? '';
$statusTabs = [
'open' => ['Offen', $tabCounts['open']],
'on_hold' => ['Pausiert', $tabCounts['on_hold']],
'resolved' => ['Gelöst', $tabCounts['resolved']],
'closed' => ['Geschlossen', $tabCounts['closed']],
'snoozed' => ['Wiedervorlage', $tabCounts['snoozed']],
'banned' => ['Gesperrt', $tabCounts['banned']],
'all' => ['Alle', array_sum($tabCounts) - $tabCounts['snoozed']],
];
$quickFilters = [
'me' => ['Mir zugewiesen', $countMe, 'var(--primary)', '#e6f4f3'],
'unassigned' => ['Nicht zugewiesen', $countUnassigned, '#64748b', '#f1f5f9'],
'overdue' => ['Überfällig', $countOverdue, '#ef4444', '#fef2f2'],
'highprio' => ['Hohe Priorität', $countHighPrio, '#f59e0b', '#fffbeb'],
];
$anyQuickActive = $activeQuick !== '';
?>
<div style="display:flex;gap:0;border-bottom:2px solid var(--border);margin-bottom:20px;flex-wrap:wrap;align-items:flex-end;position:relative">
<?php foreach ($statusTabs as $tk => [$tl, $tc]):
$isActive = $activeTab === $tk && !$anyQuickActive;
$href = '/staff/tickets.php?' . http_build_query(array_merge(array_diff_key($_GET, ['tab'=>'','page'=>'','quick'=>'']), ['tab'=>$tk]));
?>
<a href="<?= e($href) ?>"
style="padding:9px 16px;font-size:.84rem;font-weight:<?=$isActive?'700':'400'?>;color:<?=$isActive?'var(--primary)':'var(--muted)'?>;text-decoration:none;border-bottom:2px solid <?=$isActive?'var(--primary)':'transparent'?>;margin-bottom:-2px;white-space:nowrap;display:flex;align-items:center;gap:6px">
<?= $tl ?>
<span style="background:<?=$isActive?'var(--primary)':'var(--border)'?>;color:<?=$isActive?'#fff':'var(--muted)'?>;font-size:.7rem;padding:1px 7px;border-radius:20px;font-weight:700"><?= number_format($tc) ?></span>
</a>
<?php endforeach; ?>
<!-- Schnellfilter-Dropdown -->
<div style="margin-left:auto;margin-bottom:-2px;position:relative" id="qf-wrap">
<button id="qf-btn" onclick="toggleQF(event)" type="button"
style="display:flex;align-items:center;gap:8px;padding:8px 14px;font-size:.83rem;font-weight:<?=$anyQuickActive?'700':'500'?>;color:<?=$anyQuickActive?'var(--primary)':'var(--muted)'?>;background:none;border:none;border-bottom:2px solid <?=$anyQuickActive?'var(--primary)':'transparent'?>;cursor:pointer;white-space:nowrap;margin-bottom:0">
<?php if ($anyQuickActive): ?>
<span style="width:7px;height:7px;border-radius:50%;background:<?= $quickFilters[$activeQuick][2] ?>;display:inline-block;flex-shrink:0"></span>
<?= e($quickFilters[$activeQuick][0]) ?>
<span style="background:<?= $quickFilters[$activeQuick][2] ?>;color:#fff;font-size:.68rem;padding:1px 6px;border-radius:20px;font-weight:700"><?= $quickFilters[$activeQuick][1] ?></span>
<span style="font-size:.7rem;opacity:.6">✕</span>
<?php else: ?>
Schnellfilter <span style="font-size:.7rem">▾</span>
<?php if (($countOverdue + $countMe) > 0): ?>
<span style="background:#ef4444;color:#fff;font-size:.68rem;padding:1px 6px;border-radius:20px;font-weight:700"><?= $countOverdue + $countMe ?></span>
<?php endif; ?>
<?php endif; ?>
</button>
<div id="qf-panel" style="display:none;position:absolute;right:0;top:calc(100% + 6px);width:260px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow);z-index:200;padding:6px;overflow:hidden">
<?php foreach ($quickFilters as $qk => [$ql, $qc, $qcolor, $qbg]):
$qActive = $activeQuick === $qk;
$qHref = $qActive ? '/staff/tickets.php' : '/staff/tickets.php?' . http_build_query(['quick' => $qk]);
?>
<a href="<?= e($qHref) ?>"
style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;text-decoration:none;background:<?=$qActive?$qbg:'transparent'?>;transition:background .12s;margin-bottom:2px"
onmouseover="this.style.background='<?=$qActive?$qbg:"var(--border-soft)"?>'"
onmouseout="this.style.background='<?=$qActive?$qbg:"transparent"?>'">
<span style="width:8px;height:8px;border-radius:50%;background:<?=$qcolor?>;flex-shrink:0;opacity:<?=$qActive?'1':'.5'?>"></span>
<span style="flex:1;font-size:.85rem;font-weight:<?=$qActive?'600':'500'?>;color:<?=$qActive?$qcolor:'var(--ink-soft)'?>;white-space:nowrap"><?= e($ql) ?></span>
<span style="background:<?=$qc>0?$qcolor:'var(--border)'?>;color:<?=$qc>0?'#fff':'var(--muted)'?>;font-size:.72rem;padding:2px 9px;border-radius:20px;font-weight:700;flex-shrink:0"><?= $qc ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
<script>
function toggleQF(e) {
e.stopPropagation();
var p = document.getElementById('qf-panel');
p.style.display = p.style.display === 'none' ? 'block' : 'none';
}
document.addEventListener('click', function() {
var p = document.getElementById('qf-panel');
if (p) p.style.display = 'none';
});
document.getElementById('qf-panel')?.addEventListener('click', function(e){ e.stopPropagation(); });
</script>
<?php foreach ($bulkErrors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php
$__queues = queues_for($pdo, (int) $staff['id']);
$__currentFilters = queue_filters_from_web($_GET);
$__currentQuery = http_build_query($_GET);
?>
<div class="card" style="margin-bottom:12px;padding:10px 14px">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<strong style="font-size:.8rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em">Ansichten</strong>
<a href="/staff/tickets.php" class="btn secondary" style="padding:3px 12px;font-size:.82rem<?= !$_GET ? ';border-color:var(--primary);color:var(--primary)' : '' ?>">Standard</a>
<?php foreach ($__queues as $__q):
$__active = $__q['filters'] && queue_filters_to_web($__q['filters']) == array_filter($_GET, fn($v) => $v !== '' && $v !== null && $v !== []);
?>
<span style="display:inline-flex;align-items:center;gap:2px">
<a href="/staff/tickets.php?<?= e(http_build_query(queue_filters_to_web($__q['filters']))) ?>" class="btn secondary" title="<?= $__q['mine'] ? 'Meine Ansicht' : 'Geteilt von ' . e($__q['owner_name'] ?? '') ?>"
style="padding:3px 12px;font-size:.82rem<?= $__active ? ';border-color:var(--primary);color:var(--primary)' : '' ?>"><?= e($__q['name']) ?><?= $__q['is_shared'] ? ' ⇄' : '' ?></a>
<?php if (queue_can_edit($__q, $staff)): ?>
<form method="post" style="margin:0" onsubmit="return confirm('Ansicht löschen?')">
<?= csrf_field() ?><input type="hidden" name="queue_action" value="delete"><input type="hidden" name="queue_id" value="<?= (int) $__q['id'] ?>">
<button type="submit" title="Ansicht löschen" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:.9rem;padding:0 4px">✕</button>
</form>
<?php endif; ?>
</span>
<?php endforeach; ?>
<details style="margin-left:auto;position:relative">
<summary class="btn secondary" style="padding:3px 12px;font-size:.82rem;cursor:pointer;list-style:none">Aktuelle Filter als Ansicht speichern</summary>
<form method="post" class="card" style="position:absolute;right:0;top:34px;z-index:20;min-width:280px;padding:14px;box-shadow:var(--shadow-md, 0 8px 24px rgba(0,0,0,.2))">
<?= csrf_field() ?>
<input type="hidden" name="queue_action" value="save">
<input type="hidden" name="return_query" value="<?= e($__currentQuery) ?>">
<label for="queue_name" style="margin-top:0">Name der Ansicht</label>
<input type="text" id="queue_name" name="queue_name" maxlength="80" placeholder="z. B. Meine überfälligen" required>
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-top:10px"><input type="checkbox" name="queue_shared" value="1" style="width:auto"> Für alle Mitarbeiter sichtbar</label>
<p class="muted" style="font-size:.76rem;margin:8px 0 0">Gespeichert werden: <?= e($__currentFilters ? implode(', ', array_keys($__currentFilters)) : 'keine Filter (alle Tickets)') ?></p>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
</details>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<!-- ── Filter ── -->
<form method="get">
<div style="display:grid;grid-template-columns:1fr repeat(4,auto) auto;gap:10px;align-items:end;margin-bottom:10px">
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Suche</label>
<input type="search" name="q" value="<?= e($search) ?>"
placeholder="Betreff · Ticket-Nr. · Kundenname · E-Mail"
<?= $search ? 'autofocus' : '' ?>>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Abteilung</label>
<select name="department_id">
<option value="">Alle</option>
<?php foreach ($visibleDepartments as $d): ?>
<option value="<?= $d['id'] ?>" <?= $filterDept == $d['id'] ? 'selected' : '' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Status</label>
<select name="status">
<option value="">Alle außer Geschlossen</option>
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','answered'=>'Beantwortet','on_hold'=>'Pausiert','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
<option value="<?= $val ?>" <?= $filterStatus === $val ? 'selected' : '' ?>><?= $lbl ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">E-Mail</label>
<select name="spam">
<option value="">Normale Tickets</option>
<option value="1" <?= $filterSpam === '1' ? 'selected' : '' ?>>🚫 Spam</option>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Priorität</label>
<select name="priority_id">
<option value="">Alle</option>
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= $filterPrio == $p['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Zuweisung</label>
<select name="assigned">
<option value="">Alle</option>
<option value="me" <?= $filterAssign==='me'?'selected':'' ?>>Mir zugewiesen</option>
<option value="unassigned" <?= $filterAssign==='unassigned'?'selected':'' ?>>Nicht zugewiesen</option>
<?php foreach ($agents as $a): ?>
<option value="<?= $a['id'] ?>" <?= $filterAssign == $a['id'] ? 'selected' : '' ?>><?= e($a['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($allTags): ?>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">Tag</label>
<select name="tag_id">
<option value="">Alle</option>
<?php foreach ($allTags as $tg): ?>
<option value="<?= $tg['id'] ?>" <?= $filterTag == $tg['id'] ? 'selected' : '' ?>><?= e($tg['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div>
<label style="font-size:.75rem;font-weight:600;color:var(--muted);display:block;margin-bottom:3px">SLA-Status</label>
<select name="sla">
<option value="">Alle</option>
<option value="overdue" <?= $filterSla==='overdue'?'selected':'' ?>>🔴 Überfällig</option>
<option value="at_risk" <?= $filterSla==='at_risk'?'selected':'' ?>>🟡 Bald fällig (&lt; 4 Std.)</option>
<option value="on_time" <?= $filterSla==='on_time'?'selected':'' ?>>🟢 Im Zeitplan</option>
</select>
</div>
<div style="display:flex;gap:6px;align-items:flex-end">
<button type="submit" class="btn">Filtern</button>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:5px;font-size:.78rem;font-weight:400;cursor:pointer;color:var(--muted);margin:0">
<input type="checkbox" name="fulltext" value="1" style="width:auto;margin:0" <?= !empty($_GET['fulltext']) ? 'checked' : '' ?>>
Auch Nachrichten durchsuchen
</label>
<div style="flex:1"></div>
<?php if ($staff['role'] === 'admin'): ?>
<a href="/staff/export.php?type=tickets&<?= http_build_query($_GET) ?>" class="btn secondary" style="font-size:.8rem;padding:5px 12px">↓ CSV exportieren</a>
<?php endif; ?>
<?php if ($search || $filterStatus || $filterDept || $filterPrio || $filterAssign || $filterTag || $filterSpam): ?>
<a href="/staff/tickets.php" class="btn secondary" style="font-size:.8rem;padding:5px 12px;color:var(--muted)">✕ Filter zurücksetzen</a>
<?php endif; ?>
</div>
</form>
<!-- ── Sammelaktionen ── -->
<form method="post" id="bulk-form" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
<?= csrf_field() ?>
<input type="hidden" name="bulk_action" id="bulk_action" value="">
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center">
<span style="font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)">Auswahl:</span>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_assigned_staff_id" name="bulk_assigned_staff_id">
<option value="">– nicht zugewiesen –</option>
<?php foreach ($agents as $a): ?><option value="<?= $a['id'] ?>"><?= e($a['name']) ?></option><?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('assign')">Zuweisen</button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_priority_id" name="bulk_priority_id">
<?php foreach ($priorities as $p): ?><option value="<?= $p['id'] ?>"><?= e($p['name']) ?></option><?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('priority')">Priorität setzen</button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_department_id" name="bulk_department_id">
<option value="">– Abteilung wählen –</option>
<?php foreach ($visibleDepartments as $d): ?><option value="<?= (int) $d['id'] ?>"><?= e($d['name']) ?></option><?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="if(!document.getElementById('bulk_department_id').value){alert('Bitte eine Ziel-Abteilung wählen.');return;} submitBulk('department')">Abteilung ändern</button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<select id="bulk_status" name="bulk_status">
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
<option value="<?= $val ?>"><?= $lbl ?></option>
<?php endforeach; ?>
</select>
<select name="bulk_resolution_code" aria-label="Abschlussgrund für Tickets">
<option value="">Abschlussgrund …</option><?php foreach ($resolutionReasons as $code => $label): ?><option value="<?= e($code) ?>"><?= e($label) ?></option><?php endforeach; ?>
</select>
<button type="button" class="btn secondary" style="white-space:nowrap" onclick="submitBulk('status')">Status setzen</button>
</div>
<div style="margin-left:auto;display:flex;gap:8px">
<button type="button" class="btn secondary" style="border-color:#f59e0b;color:#92400e;white-space:nowrap"
onclick="if(confirm('Alle Absender der markierten Tickets sperren und Tickets schließen?')) submitBulk('ban')">⛔ Absender sperren</button>
<button type="button" class="btn danger" style="white-space:nowrap"
onclick="if(confirm('Markierte Tickets wirklich unwiderruflich löschen?')) submitBulk('delete')">Löschen</button>
</div>
</div>
</form>
</div>
<?php require __DIR__ . '/_tickets_list_partial.php'; ?>
<script>
function submitBulk(action) {
const checked = document.querySelectorAll('.ticket-check:checked');
if (checked.length === 0) { alert('Bitte mindestens ein Ticket markieren.'); return; }
// Copy checked ticket IDs into the top bulk-form and submit
const form = document.getElementById('bulk-form');
document.getElementById('bulk_action').value = action;
// Remove any previously cloned ticket_id inputs
form.querySelectorAll('input[name="ticket_ids[]"]').forEach(el => el.remove());
// Clone the checked ticket IDs into the bulk-form
checked.forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'ticket_ids[]';
inp.value = cb.value;
form.appendChild(inp);
});
form.submit();
}
// Live-Refresh: holt alle paar Sekunden die aktuelle Ticketliste (gleiche Filter/Seite
// über die URL) im Hintergrund und tauscht nur die Tabelle aus – so tauchen neue oder
// geänderte Tickets automatisch auf, ohne dass ein Mitarbeiter die Seite neu laden muss.
// Pausiert bewusst, solange gerade Tickets markiert sind (Sammelaktion in Vorbereitung)
// oder der Nutzer gerade in einem Formularfeld tippt/eine Auswahl offen hat, damit nichts
// unter den Fingern weggezogen wird.
(function () {
var region = document.getElementById('tickets-live-region');
if (!region) return;
var refreshing = false;
function partialUrl() {
var params = new URLSearchParams(window.location.search);
params.set('partial', '1');
return window.location.pathname + '?' + params.toString();
}
function refreshTicketList() {
if (refreshing) return;
if (document.querySelectorAll('.ticket-check:checked').length > 0) return;
var active = document.activeElement;
if (active && ['INPUT', 'SELECT', 'TEXTAREA'].includes(active.tagName)) return;
refreshing = true;
fetch(partialUrl(), { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
var fresh = tmp.querySelector('#tickets-live-region');
if (fresh) {
region.replaceWith(fresh);
region = fresh;
}
})
.catch(function () {})
.finally(function () { refreshing = false; });
}
setInterval(refreshTicketList, 15000);
})();
</script>
<?php require __DIR__ . '/_footer.php'; ?>