prepare( '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 WHERE t.ticket_number = ?' ); $stmt->execute([$number]); $ticket = $stmt->fetch(); if (!$ticket) { flash('error', 'Dieses Ticket wurde nicht gefunden.'); redirect('/staff/tickets.php'); } if ($myDeptIds !== null && !in_array((int) $ticket['department_id'], $myDeptIds, true)) { flash('error', 'Sie haben keinen Zugriff auf Tickets dieser Abteilung.'); redirect('/staff/tickets.php'); } // Zusammengeführte Tickets die in dieses hier führen $mergedFrom = []; if (empty($ticket['merged_into'])) { $mfStmt = $pdo->prepare( 'SELECT ticket_number, subject, requester_name, created_at FROM tickets WHERE merged_into = ? ORDER BY created_at DESC' ); $mfStmt->execute([$ticket['id']]); $mergedFrom = $mfStmt->fetchAll(); } // Wenn dieses Ticket selbst zusammengeführt wurde: Banner anzeigen (NICHT umleiten) $mergedIntoBanner = null; if (!empty($ticket['merged_into'])) { $stmt = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?'); $stmt->execute([$ticket['merged_into']]); $mergedIntoBanner = $stmt->fetchColumn() ?: null; } // Ticket-Sperre: verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket // arbeiten (geteilte Logik mit der Mobile-App, siehe ticket_locked_by_other() in // includes/functions.php). Eine Sperre gilt als abgelaufen, wenn sie laenger als // TICKET_LOCK_TIMEOUT_MINUTES nicht mehr aufgefrischt wurde (z.B. weil der Tab einfach // geschlossen wurde). $lockedByOther = null; $adminLockedBy = null; if ($_SERVER['REQUEST_METHOD'] === 'GET') { $lockedByName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo); if ($lockedByName && $staff['role'] === 'admin') { // Administratoren dürfen auch ein von anderen bearbeitetes Ticket lesen und bearbeiten; die Sperre des anderen bleibt bestehen // (siehe Hinweis oben auf der Seite, dort lässt sie sich aufheben) $adminLockedBy = $lockedByName; } elseif ($lockedByName) { flash('error', "Dieses Ticket wird gerade von \"{$lockedByName}\" bearbeitet und kann nicht gleichzeitig geöffnet werden. Bitte warten Sie, bis die Bearbeitung abgeschlossen ist, oder bitten Sie {$lockedByName}, das Ticket freizugeben."); flash_locked_ticket($number, $lockedByName); redirect('/staff/tickets.php'); } else { ticket_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo); $ticket['locked_by'] = $staff['id']; // Automatische Zeiterfassung: Bearbeitung dieses Tickets beginnt (oder wird // aufgefrischt, falls schon eine Sitzung läuft) – jede andere noch offene // Sitzung desselben Mitarbeiters wird abgeschlossen, da er nun hier weiterarbeitet. start_or_touch_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); finalize_other_open_sessions($pdo, (int) $staff['id'], (int) $ticket['id']); } } $lockedByOther = null; // wird ab jetzt nicht mehr gebraucht (kein Banner-Modus mehr) // Für den "Zeiterfassung läuft"-Hinweis in der Titelzeile $myWorkSessionStmt = $pdo->prepare('SELECT started_at FROM ticket_work_sessions WHERE ticket_id = ? AND staff_id = ?'); $myWorkSessionStmt->execute([$ticket['id'], $staff['id']]); $myWorkSessionStartedAt = $myWorkSessionStmt->fetchColumn() ?: null; $collabStmt = $pdo->prepare('SELECT * FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at'); $collabStmt->execute([$ticket['id']]); $collaborators = $collabStmt->fetchAll(); $departments = departments_for_select($pdo, [(int) $ticket['department_id']]); $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(); $teams = $pdo->query('SELECT id, name FROM teams WHERE is_active = 1 ORDER BY name')->fetchAll(); // Nur Vorlagen dieser Abteilung und solche, die für alle Abteilungen gelten (keine Abteilung zugeordnet) $cannedStmt = $pdo->prepare('SELECT id, title, body, department_id FROM canned_responses WHERE department_id IS NULL OR department_id = ? ORDER BY title'); $cannedStmt->execute([(int) $ticket['department_id']]); $cannedResponses = $cannedStmt->fetchAll(); $helpTopicName = null; if (!empty($ticket['help_topic_id'])) { $stmt = $pdo->prepare('SELECT name FROM help_topics WHERE id = ?'); $stmt->execute([$ticket['help_topic_id']]); $helpTopicName = $stmt->fetchColumn() ?: null; } $organizationName = null; if (!empty($ticket['user_id'])) { $stmt = $pdo->prepare('SELECT o.name FROM users u JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?'); $stmt->execute([$ticket['user_id']]); $organizationName = $stmt->fetchColumn() ?: null; } $fieldStmt = $pdo->prepare( "SELECT cf.label, cf.field_type, v.value FROM ticket_field_values v JOIN custom_fields cf ON cf.id = v.custom_field_id WHERE v.ticket_id = ? ORDER BY cf.sort_order, cf.label" ); $fieldStmt->execute([$ticket['id']]); $customFieldValues = $fieldStmt->fetchAll(); $tagStmt = $pdo->prepare( "SELECT t.id, t.name, t.color FROM tags t JOIN ticket_tags tt ON tt.tag_id = t.id WHERE tt.ticket_id = ? ORDER BY t.name" ); $tagStmt->execute([$ticket['id']]); $ticketTags = $tagStmt->fetchAll(); $allTags = $pdo->query('SELECT name FROM tags ORDER BY name')->fetchAll(PDO::FETCH_COLUMN); $errors = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Kunden sperren (eigener Block ohne globale verify_csrf) if (($_POST['action'] ?? '') === 'ban_user') { verify_csrf(); $banEmail = trim($_POST['ban_email'] ?? ''); $banDomain = trim($_POST['ban_domain'] ?? ''); $banReason = trim($_POST['ban_reason'] ?? ''); $banned = []; foreach (array_filter([$banEmail, $banDomain]) as $val) { $type = str_contains($val, '@') ? 'email' : 'domain'; $chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?'); $chk->execute([$val]); if (!$chk->fetchColumn()) { $pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)') ->execute([$type, $val, $banReason ?: null, $staff['id'], db_now()]); $banned[] = $val; audit_log($staff['id'], "Gesperrt: $val (Ticket #{$ticket['ticket_number']})"); } } if ($banEmail) { $affected = $pdo->prepare( "UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE requester_email=? AND status NOT IN ('closed')" ); $affected->execute([db_now(), db_now(), $banEmail]); } if ($banDomain) { $affected2 = $pdo->prepare( "UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')" ); $affected2->execute([db_now(), db_now(), $banDomain]); } flash('success', 'Gesperrt und alle zugehörigen Tickets geschlossen.'); redirect('/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number'])); } verify_csrf(); $action = $_POST['action'] ?? ''; $now = db_now(); // Rechte gelten pro Abteilung des Tickets (Rolle des Mitarbeiters in dieser Abteilung). $requirePerm = function (string $perm) use ($staff, $ticket, $number): void { if (!has_permission($staff, $perm, (int) $ticket['department_id'])) { flash('error', 'Für diese Aktion fehlt die erforderliche Berechtigung.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } }; if ($action === 'reply') $requirePerm('reply_ticket'); if ($action === 'note') $requirePerm('post_note'); if ($action === 'delete_ticket') $requirePerm('delete_ticket'); if ($action === 'mark_spam' || $action === 'restore_spam') $requirePerm('close_ticket'); if ($action === 'add_comment') { $threadId = (int)($_POST['thread_id'] ?? 0); $message = trim($_POST['comment_message'] ?? ''); if ($threadId && $message !== '') { // Verify thread belongs to this ticket $chk = $pdo->prepare('SELECT id FROM ticket_threads WHERE id = ? AND ticket_id = ?'); $chk->execute([$threadId, $ticket['id']]); if ($chk->fetchColumn()) { $pdo->prepare( 'INSERT INTO ticket_thread_comments (thread_id, staff_id, message, created_at) VALUES (?,?,?,?)' )->execute([$threadId, $staff['id'], htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), $now]); } } redirect('/staff/ticket_view.php?n=' . urlencode($number) . '#msg-' . $threadId); } if ($action === 'delete_comment') { $commentId = (int)($_POST['comment_id'] ?? 0); // Only allow deletion by the comment author or admin $chk = $pdo->prepare( 'SELECT tc.id FROM ticket_thread_comments tc JOIN ticket_threads tt ON tt.id = tc.thread_id WHERE tc.id = ? AND tt.ticket_id = ? AND (tc.staff_id = ? OR ? = \'admin\')' ); $chk->execute([$commentId, $ticket['id'], $staff['id'], $staff['role']]); if ($chk->fetchColumn()) { $pdo->prepare('DELETE FROM ticket_thread_comments WHERE id = ?')->execute([$commentId]); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'reply' || $action === 'note') { $raw = trim($_POST['message'] ?? ''); // Variablen serverseitig auflösen (Fallback falls JS deaktiviert) $raw = resolve_canned_vars($raw, $ticket, $staff); $message = sanitize_staff_html($raw); if (trim(strip_tags($message)) === '') { $errors[] = 'Bitte geben Sie eine Nachricht ein.'; } else { $isNote = $action === 'note' ? 1 : 0; $stmt = $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)' ); $stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote, $now]); $threadId = (int) $pdo->lastInsertId(); if (!empty($_FILES['attachments']['name'][0])) { $upResults = handle_ticket_uploads_multi($_FILES['attachments']); foreach ($upResults as $up) { if (isset($up['error'])) { $errors[] = $up['error']; } else { $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)') ->execute([$threadId, $up['original_name'], $up['stored_name'], $up['filesize'], $now]); } } } if (!$isNote) { $newStatus = $_POST['set_status'] ?: 'answered'; $updateFirstResponse = $ticket['first_responded_at'] === null; // Auto-Zuweisung an antwortenden Mitarbeiter, falls Ticket noch niemandem zugewiesen ist $autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === ''); $stmt = $pdo->prepare( 'UPDATE tickets SET status = ?, updated_at = ?' . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '') . ' WHERE id = ?' ); $args = []; $args[] = $newStatus; $args[] = $now; if ($updateFirstResponse) $args[] = $now; if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $stmt->execute($args); if ($autoAssign) { log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (erste Antwort)'); } // Signatur laut Auswahl im Antwortformular (eigene, Abteilung oder keine); ohne Angabe wie bisher $signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], isset($_POST['signature_choice']) ? (string) $_POST['signature_choice'] : null); $ccEmails = array_column($collaborators, 'email'); notify_customer_new_reply($ticket, $message, $signature, $ccEmails); save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); log_activity($pdo, $ticket['id'], $staff['id'], 'Öffentliche Antwort gesendet'); // Browser-Push bei neuer Kundenantwort if (file_exists(__DIR__ . '/../includes/push.php')) { require_once __DIR__ . '/../includes/push.php'; push_new_reply($ticket, $staff['name']); } } else { save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); log_activity($pdo, $ticket['id'], $staff['id'], 'Interne Notiz hinzugefügt'); } if (!$errors) { // Sperre freigeben: die aktive Bearbeitung ist abgeschlossen, ein anderer // Mitarbeiter kann das Ticket sofort uebernehmen, ohne auf das Timeout zu warten. $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => (bool) $isNote]); flash('success', $isNote ? 'Notiz hinzugefügt.' : 'Antwort gesendet.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } } } if ($action === 'mark_spam' || $action === 'restore_spam') { $result = set_ticket_spam($pdo, $ticket, (int) $staff['id'], $action === 'restore_spam'); if ($result['error']) { flash('error', $result['error']); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($result['failed']) { $verb = $action === 'restore_spam' ? 'zurückgeholt' : 'als Spam markiert'; flash('error', "Ticket wurde $verb, aber nicht alle Original-Mails konnten verschoben werden: " . implode(' | ', $result['failed'])); } elseif ($action === 'restore_spam') { flash('success', "Ticket #{$number} ist nicht mehr als Spam markiert. {$result['moved']} E-Mail(s) zurückverschoben" . ($result['already'] ? ", {$result['already']} bereits dort." : '.')); } else { flash('success', "Ticket #{$number} als Spam markiert. {$result['moved']} E-Mail(s) nach Spam/Junk verschoben" . ($result['already'] ? ", {$result['already']} bereits dort." : '.')); } redirect($action === 'restore_spam' ? '/staff/ticket_view.php?n=' . urlencode($number) : '/staff/tickets.php'); } if ($action === 'delete_ticket') { delete_ticket($pdo, $ticket, $staff['id']); flash('success', "Ticket #{$number} wurde gelöscht."); redirect('/staff/tickets.php'); } if ($action === 'add_tag') { $tagName = trim($_POST['tag_name'] ?? ''); if ($tagName !== '') { apply_tag_to_ticket($pdo, $ticket['id'], $tagName); log_activity($pdo, $ticket['id'], $staff['id'], "Tag hinzugefügt: {$tagName}"); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'remove_tag') { $tagId = (int) ($_POST['tag_id'] ?? 0); $pdo->prepare('DELETE FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?')->execute([$ticket['id'], $tagId]); log_activity($pdo, $ticket['id'], $staff['id'], 'Tag entfernt'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'add_collaborator') { $ccEmail = trim($_POST['collaborator_email'] ?? ''); $ccName = trim($_POST['collaborator_name'] ?? ''); if (!filter_var($ccEmail, FILTER_VALIDATE_EMAIL)) { flash('error', 'Bitte eine gültige E-Mail-Adresse für den Kollaborator angeben.'); } else { $dupStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_collaborators WHERE ticket_id = ? AND email = ?'); $dupStmt->execute([$ticket['id'], $ccEmail]); if ((int) $dupStmt->fetchColumn() === 0) { $pdo->prepare('INSERT INTO ticket_collaborators (ticket_id, email, name, added_by, created_at) VALUES (?,?,?,?,?)') ->execute([$ticket['id'], $ccEmail, $ccName ?: null, $staff['id'], db_now()]); log_activity($pdo, $ticket['id'], $staff['id'], "Kollaborator hinzugefügt: {$ccEmail}"); flash('success', 'Kollaborator hinzugefügt.'); } } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'remove_collaborator') { $ccId = (int) ($_POST['collaborator_id'] ?? 0); $emailStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE id = ? AND ticket_id = ?'); $emailStmt->execute([$ccId, $ticket['id']]); $ccEmail = $emailStmt->fetchColumn(); $pdo->prepare('DELETE FROM ticket_collaborators WHERE id = ? AND ticket_id = ?')->execute([$ccId, $ticket['id']]); if ($ccEmail) log_activity($pdo, $ticket['id'], $staff['id'], "Kollaborator entfernt: {$ccEmail}"); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'release_lock') { $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); log_activity($pdo, $ticket['id'], $staff['id'], 'Sperre manuell aufgehoben'); flash('success', 'Sperre aufgehoben.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } // Admin-Override: hebt die Sperre eines ANDEREN Mitarbeiters sofort auf, statt bis zum // automatischen Timeout zu warten (siehe flash_locked_ticket() / TICKET_LOCK_TIMEOUT_MINUTES // in includes/functions.php). Läuft bewusst über POST statt über die GET-Sperrprüfung // oben, da genau die hier umgangen werden soll – daher die eigene Rechteprüfung hier. if ($action === 'admin_force_unlock') { if ($staff['role'] !== 'admin') { flash('error', 'Nur Admins können fremde Sperren aufheben.'); redirect('/staff/tickets.php'); } $unlockedFromName = $_POST['locked_by_name'] ?? 'einem anderen Mitarbeiter'; $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]); log_activity($pdo, $ticket['id'], $staff['id'], "Sperre von \"{$unlockedFromName}\" durch Admin aufgehoben"); flash('success', "Sperre aufgehoben – Sie können das Ticket jetzt öffnen."); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'merge_ticket') { $targetNumber = trim($_POST['target_ticket_number'] ?? ''); $direction = $_POST['merge_direction'] ?? 'into'; // 'into' = dieses → Ziel $stmt = $pdo->prepare( 'SELECT t.*, p.name AS priority_name, 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 WHERE t.ticket_number = ?' ); $stmt->execute([$targetNumber]); $target = $stmt->fetch(); if (!$target) { flash('error', 'Ziel-Ticket #' . e($targetNumber) . ' wurde nicht gefunden.'); } elseif ($target['id'] === $ticket['id']) { flash('error', 'Ein Ticket kann nicht mit sich selbst zusammengeführt werden.'); } elseif (!empty($target['merged_into'])) { flash('error', 'Das Ziel-Ticket wurde bereits in ein anderes Ticket zusammengeführt.'); } elseif ($myDeptIds !== null && !in_array((int) $target['department_id'], $myDeptIds, true)) { flash('error', 'Sie haben keinen Zugriff auf die Abteilung des Ziel-Tickets.'); } else { // Bestimme Quelle und Ziel je nach Richtung if ($direction === 'from') { // Ziel-Ticket wird in dieses zusammengeführt [$src, $dst] = [$target, $ticket]; } else { // Dieses Ticket wird ins Ziel zusammengeführt (Standard) [$src, $dst] = [$ticket, $target]; } $pdo->beginTransaction(); try { // Threads verschieben $pdo->prepare('UPDATE ticket_threads SET ticket_id = ? WHERE ticket_id = ?') ->execute([$dst['id'], $src['id']]); // Collaborators übernehmen (cross-DB) $isSqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'; $insertIgnore = $isSqlite ? 'INSERT OR IGNORE' : 'INSERT IGNORE'; try { $pdo->prepare( "$insertIgnore INTO ticket_collaborators (ticket_id, user_id, created_at) SELECT ?, user_id, created_at FROM ticket_collaborators WHERE ticket_id = ?" )->execute([$dst['id'], $src['id']]); } catch (Throwable $e) { /* Tabelle leer oder keine Collaborators */ } // Tags übernehmen try { $pdo->prepare( "$insertIgnore INTO ticket_tags (ticket_id, tag_id) SELECT ?, tag_id FROM ticket_tags WHERE ticket_id = ?" )->execute([$dst['id'], $src['id']]); } catch (Throwable $e) { /* ignorieren */ } // System-Notiz im Ziel-Ticket $note = "Ticket #{$src['ticket_number']} wurde hierher zusammengeführt" . " (Betreff: " . htmlspecialchars($src['subject'], ENT_QUOTES) . ")"; $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,1,?)' )->execute([$dst['id'], 'staff', $staff['id'], $note, $now]); // Quell-Ticket schließen + merged_into setzen $pdo->prepare('UPDATE tickets SET status = ?, merged_into = ?, updated_at = ? WHERE id = ?') ->execute(['closed', $dst['id'], $now, $src['id']]); // Ziel-Ticket aktualisieren (updated_at) $pdo->prepare('UPDATE tickets SET updated_at = ? WHERE id = ?') ->execute([$now, $dst['id']]); $pdo->commit(); log_activity($pdo, $dst['id'], $staff['id'], "Ticket #{$src['ticket_number']} hierher zusammengeführt"); log_activity($pdo, $src['id'], $staff['id'], "Zusammengeführt mit Ticket #{$dst['ticket_number']}"); audit_log($staff['id'], "Merge: #{$src['ticket_number']} → #{$dst['ticket_number']}"); flash('success', "Ticket #{$src['ticket_number']} wurde erfolgreich mit #{$dst['ticket_number']} zusammengeführt."); redirect('/staff/ticket_view.php?n=' . urlencode($dst['ticket_number'])); } catch (Throwable $e) { $pdo->rollBack(); flash('error', 'Fehler beim Zusammenführen: ' . $e->getMessage()); } } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'update_props') { $deptId = (int) $_POST['department_id']; if ($myDeptIds !== null && !in_array($deptId, $myDeptIds, true) && $deptId !== (int) $ticket['department_id']) { flash('error', 'Sie haben keinen Zugriff auf diese Abteilung.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } $prioId = (int) $_POST['priority_id']; $status = $_POST['status']; $assignedTo = $_POST['assigned_staff_id'] !== '' ? (int) $_POST['assigned_staff_id'] : null; $teamId = ($_POST['team_id'] ?? '') !== '' ? (int) $_POST['team_id'] : null; if ($deptId !== (int) $ticket['department_id']) $requirePerm('transfer_ticket'); if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id']) || $teamId !== ($ticket['team_id'] === null ? null : (int) $ticket['team_id'])) $requirePerm('assign_ticket'); if ($status !== $ticket['status'] && in_array($status, ['resolved', 'closed'], true)) $requirePerm('close_ticket'); $prio = null; foreach ($priorities as $p) { if ($p['id'] == $prioId) $prio = $p; } $sla = $prio ? calc_sla_dates($prio, $ticket['created_at'], $deptId, !empty($ticket['help_topic_id']) ? (int) $ticket['help_topic_id'] : null) : null; $resolvedAt = $ticket['resolved_at']; $closedAt = $ticket['closed_at']; if ($status === 'resolved' && $ticket['status'] !== 'resolved') $resolvedAt = $now; if ($status === 'closed' && $ticket['status'] !== 'closed') $closedAt = $now; $stmt = $pdo->prepare( 'UPDATE tickets SET department_id=?, priority_id=?, status=?, assigned_staff_id=?, team_id=?, first_response_due_at=?, resolution_due_at=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?' ); $stmt->execute([ $deptId, $prioId, $status, $assignedTo, $teamId, $sla['first_response_due_at'] ?? $ticket['first_response_due_at'], $sla['resolution_due_at'] ?? $ticket['resolution_due_at'], $resolvedAt, $closedAt, $now, $ticket['id'], ]); if ($assignedTo !== (int) ($ticket['assigned_staff_id'] ?? 0) && $assignedTo !== null) { $agentName = ''; foreach ($agents as $a) { if ($a['id'] == $assignedTo) $agentName = $a['name']; } log_activity($pdo, $ticket['id'], $staff['id'], "Mitarbeiter zugewiesen: {$agentName}"); fcm_notify_assignment($ticket, (int) $assignedTo, (int) $staff['id']); // Push auf die Android-App des neuen Bearbeiters } elseif ($assignedTo === null && !empty($ticket['assigned_staff_id'])) { log_activity($pdo, $ticket['id'], $staff['id'], 'Zuweisung entfernt'); } if ($teamId !== null && $teamId !== (int) ($ticket['team_id'] ?? 0)) { log_activity($pdo, $ticket['id'], $staff['id'], 'Team zugewiesen'); notify_team_assignment($pdo, $ticket, $teamId, (int) $staff['id']); } if ($status !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]); if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id'])) webhook_dispatch($pdo, 'ticket.assigned', (int) $ticket['id']); if ($status !== $ticket['status']) { [$newLabel] = status_label($status); log_activity($pdo, $ticket['id'], $staff['id'], "Status geändert: {$newLabel}"); } if ($prioId != $ticket['priority_id']) { log_activity($pdo, $ticket['id'], $staff['id'], 'Priorität geändert'); } if ($deptId != $ticket['department_id']) { log_activity($pdo, $ticket['id'], $staff['id'], 'Abteilung geändert'); } flash('success', 'Ticket aktualisiert.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } $stmt = $pdo->prepare( '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 WHERE t.ticket_number = ?' ); $stmt->execute([$number]); $ticket = $stmt->fetch(); } if ($action === 'log_time') { $minutes = max(1, (int) ($_POST['minutes'] ?? 0)); $note = trim($_POST['time_note'] ?? ''); $pdo->prepare('INSERT INTO ticket_time_entries (ticket_id,staff_id,minutes,note,logged_at) VALUES (?,?,?,?,?)') ->execute([$ticket['id'], $staff['id'], $minutes, $note ?: null, db_now()]); log_activity($pdo, $ticket['id'], $staff['id'], "Zeit erfasst: {$minutes} Min." . ($note ? " ({$note})" : '')); flash('success', 'Zeit erfasst.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'delete_time_entry') { $pdo->prepare('DELETE FROM ticket_time_entries WHERE id=? AND staff_id=?') ->execute([(int)$_POST['entry_id'], $staff['id']]); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } // Thread (inkl. interner Notizen) laden $stmt = $pdo->prepare( "SELECT tt.*, s.name AS staff_name, u.name AS user_name FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id LEFT JOIN users u ON u.id = tt.user_id WHERE tt.ticket_id = ? ORDER BY tt.created_at ASC" ); $stmt->execute([$ticket['id']]); $thread = $stmt->fetchAll(); $attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?'); $commentStmt = $pdo->prepare( 'SELECT tc.*, s.name AS staff_name, s.avatar_path FROM ticket_thread_comments tc JOIN staff s ON s.id = tc.staff_id WHERE tc.thread_id = ? ORDER BY tc.created_at ASC' ); $logStmt = $pdo->prepare( "SELECT al.*, s.name AS staff_name FROM activity_log al LEFT JOIN staff s ON s.id=al.staff_id WHERE al.ticket_id=? ORDER BY al.created_at DESC LIMIT 20" ); $logStmt->execute([$ticket['id']]); $activity = $logStmt->fetchAll(); // Zeiterfassung laden $timeEntries = $pdo->prepare( "SELECT te.*, s.name AS staff_name FROM ticket_time_entries te JOIN staff s ON s.id=te.staff_id WHERE te.ticket_id=? ORDER BY te.logged_at DESC" ); $timeEntries->execute([$ticket['id']]); $timeEntries = $timeEntries->fetchAll(); $totalMinutes = array_sum(array_column($timeEntries, 'minutes')); $mentionNames = $pdo->query('SELECT name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll(PDO::FETCH_COLUMN); $pageTitle = "Ticket #{$ticket['ticket_number']}"; $activePage = 'tickets'; require __DIR__ . '/_header.php'; [$statusLabel, $statusColor] = status_label($ticket['status']); $isOverdue = $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved','closed']); // Bewertung laden $ticketRating = null; if (in_array($ticket['status'], ['resolved','closed'])) { $rStmt = $pdo->prepare('SELECT * FROM ticket_ratings WHERE ticket_id=?'); $rStmt->execute([$ticket['id']]); $ticketRating = $rStmt->fetch(); } ?>
'🌐 Web','email'=>'📧 E-Mail','phone'=>'📞 Telefon','staff'=>'🖥 Intern','chat'=>'💬 Live-Chat']; $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source']; ?>' . nl2br(htmlspecialchars($p, ENT_QUOTES, 'UTF-8')) . '
'; } } else { echo '' . nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')) . '
'; } ?>Erhalten eine Kopie jeder öffentlichen Antwort per E-Mail.
Noch keine Zeit erfasst.
"= e($ticketRating['comment']) ?>"
Noch keine Bewertung abgegeben.