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(); // --------------------------------------------------------------- // 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
Ansichten Standard $v !== '' && $v !== null && $v !== []); ?>
Aktuelle Filter als Ansicht speichern

Gespeichert werden:

>
↓ CSV exportieren ✕ Filter zurücksetzen
Auswahl: