759 lines
44 KiB
PHP
759 lines
44 KiB
PHP
<?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();
|
||
if (!has_permission($staff, 'fetch_mail')) {
|
||
flash('error', 'Für den E-Mail-Abruf fehlt die erforderliche Berechtigung.');
|
||
redirect('/staff/tickets.php');
|
||
}
|
||
$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) && has_permission($staff, 'fetch_mail');
|
||
$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']) && has_permission($staff, 'share_views'));
|
||
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;
|
||
}
|
||
// Rechte je Ticket (in der Abteilung des Tickets): Sammelaktionen dürfen nicht mehr als die Einzelaktionen im Ticket
|
||
$needPerm = ['assign' => 'assign_ticket', 'status' => in_array((string) ($_POST['bulk_status'] ?? ''), ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket',
|
||
'delete' => 'delete_ticket', 'ban' => 'manage_banlist'][$bulkAction] ?? null;
|
||
if ($bulkAction === 'priority') $needPerm = 'assign_ticket';
|
||
if ($needPerm !== null && !has_permission($staff, $needPerm, (int) $t['department_id'])) { $bulkDenied = ($bulkDenied ?? 0) + 1; continue; }
|
||
if ($bulkAction === 'ban' && !has_permission($staff, 'close_ticket', (int) $t['department_id'])) { $bulkDenied = ($bulkDenied ?? 0) + 1; 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';
|
||
$okCount = count($allowedTickets) - (int) ($bulkDenied ?? 0);
|
||
if ($okCount > 0) flash('success', $okCount . ' Ticket(s) ' . $verb . '.');
|
||
if (!empty($bulkDenied)) flash('error', (int) $bulkDenied . ' Ticket(s) übersprungen: Dafür fehlt die Berechtigung.');
|
||
} 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:<?= has_permission($staff, 'share_views') ? 'flex' : 'none' ?>;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 (< 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 (has_permission($staff, 'export_tickets')): ?>
|
||
<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">
|
||
<?php if (has_permission($staff, 'manage_banlist') && has_permission($staff, 'close_ticket')): ?>
|
||
<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>
|
||
<?php endif; ?>
|
||
<?php if (has_permission($staff, 'delete_ticket')): ?>
|
||
<button type="button" class="btn danger" style="white-space:nowrap"
|
||
onclick="if(confirm('Markierte Tickets wirklich unwiderruflich löschen?')) submitBulk('delete')">Löschen</button>
|
||
<?php endif; ?>
|
||
</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'; ?>
|