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']; ticket_assign_on_open($pdo, $ticket, $staff); // unzugewiesenes Ticket wird dem öffnenden Mitarbeiter zugewiesen // 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) if ($_SERVER['REQUEST_METHOD'] === 'GET') ticket_ack($pdo, (int) $ticket['id'], (int) $staff['id']); // gesehen: verschwindet aus dem Zähler der Apps // 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(); $ticketProblems = ticket_problems_for_ticket($pdo, (int) $ticket['id']); $scheduledReplies = scheduled_replies_pending($pdo, (int) $ticket['id']); $ticketLinks = ticket_links_for($pdo, (int) $ticket['id'], staff_department_ids($staff)); $openProblems = ticket_problems($pdo, true); $resolutionReasons = ticket_resolution_reasons(); $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 = []; $snoozedUntil = null; $followers = []; $isFollowing = false; $similar = ['articles' => [], 'tickets' => []]; if ($_SERVER['REQUEST_METHOD'] !== 'POST') { try { $snoozedUntil = !empty($ticket['snoozed_until']) && $ticket['snoozed_until'] > db_now() ? $ticket['snoozed_until'] : null; $followers = ticket_followers($pdo, (int) $ticket['id']); foreach ($followers as $f) if ((int) $f['id'] === (int) $staff['id']) $isFollowing = true; $similar = ticket_similar($pdo, $ticket, staff_department_ids($staff)); } catch (Throwable $e) { // Datenbank noch nicht aktualisiert: Zusatzfunktionen bleiben leer } } if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Kunden sperren (eigener Block ohne globale verify_csrf) if (($_POST['action'] ?? '') === 'ban_user') { verify_csrf(); if (!has_permission($staff, 'manage_banlist')) { flash('error', 'Für die Sperrliste fehlt die erforderliche Berechtigung.'); redirect('/staff/ticket_view.php?n=' . urlencode((string) ($_GET['n'] ?? ''))); } $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) { $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE requester_email=? AND status NOT IN ('closed')"); $priorStmt->execute([$banEmail]); $priorTickets = $priorStmt->fetchAll(); $affected = $pdo->prepare( "UPDATE tickets SET status='closed', resolution_code='spam', resolution_note='Absender gesperrt', closed_at=?, updated_at=? WHERE requester_email=? AND status NOT IN ('closed')" ); $affected->execute([db_now(), db_now(), $banEmail]); ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']); } if ($banDomain) { $priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')"); $priorStmt->execute([$banDomain]); $priorTickets = $priorStmt->fetchAll(); $affected2 = $pdo->prepare( "UPDATE tickets SET status='closed', resolution_code='spam', resolution_note='Domain gesperrt', closed_at=?, updated_at=? WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')" ); $affected2->execute([db_now(), db_now(), $banDomain]); ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']); } 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' || $action === 'schedule_reply' || $action === 'cancel_scheduled_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 === 'reload_mail_attachments') $requirePerm('reply_ticket'); if ($action === 'merge_ticket') $requirePerm('merge_split_ticket'); if ($action === 'link_add' || $action === 'link_remove') $requirePerm('link_tickets'); 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 === 'schedule_reply') { $message = sanitize_staff_html(resolve_canned_vars(trim($_POST['message'] ?? ''), $ticket, $staff)); $err = null; if (!empty($_FILES['attachments']['name'][0])) $err = 'Anhänge sind bei zeitversetztem Senden nicht möglich. Bitte ohne Anhang planen oder sofort senden.'; $status = (string) ($_POST['set_status'] ?? 'answered'); if (!$err && in_array($status, ['resolved', 'closed'], true)) $requirePerm('close_ticket'); $sendAt = scheduled_reply_parse_time((string) ($_POST['send_at'] ?? '')); $err = $err ?: scheduled_reply_create($pdo, $ticket, (int) $staff['id'], $message, $sendAt, [ 'set_status' => $status, 'resolution_code' => (string) ($_POST['resolution_code'] ?? ''), 'resolution_note' => (string) ($_POST['resolution_note'] ?? ''), 'signature_choice' => isset($_POST['signature_choice']) ? (string) $_POST['signature_choice'] : null, ]); if ($err) { flash('error', $err); } else { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Antwort eingeplant für ' . format_dt($sendAt)); flash('success', 'Antwort eingeplant für ' . format_dt($sendAt) . '. Sie wird dann automatisch gesendet.'); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'cancel_scheduled_reply') { if (scheduled_reply_cancel($pdo, (int) ($_POST['scheduled_id'] ?? 0), (int) $ticket['id'], $staff)) { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Geplante Antwort abgebrochen'); flash('success', 'Geplante Antwort abgebrochen.'); } else { flash('error', 'Die geplante Antwort konnte nicht abgebrochen werden (schon gesendet oder nicht von Ihnen).'); } 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; $newStatus = !$isNote ? (string) ($_POST['set_status'] ?? 'answered') : (string) $ticket['status']; $resolutionCode = (string) ($_POST['resolution_code'] ?? ''); $resolutionNote = trim(mb_substr((string) ($_POST['resolution_note'] ?? ''), 0, 2000)); if (!$isNote && in_array($newStatus, ['resolved', 'closed'], true)) { $requirePerm('close_ticket'); if (!isset(ticket_resolution_reasons()[$resolutionCode])) { flash('error', 'Bitte beim Schließen einen Abschlussgrund auswählen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } } $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) { $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'] === ''); $resolvedAt = $newStatus === 'resolved' && empty($ticket['resolved_at']) ? $now : $ticket['resolved_at']; $closedAt = $newStatus === 'closed' && empty($ticket['closed_at']) ? $now : $ticket['closed_at']; $stmt = $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ?, first_responded_at = COALESCE(first_responded_at, ?), assigned_staff_id = COALESCE(assigned_staff_id, ?), resolved_at = ?, closed_at = ?, resolution_code = ?, resolution_note = ? WHERE id = ?'); $stmt->execute([ $newStatus, $now, $now, (int) $staff['id'], $resolvedAt, $closedAt, in_array($newStatus, ['resolved', 'closed'], true) ? $resolutionCode : $ticket['resolution_code'], in_array($newStatus, ['resolved', 'closed'], true) ? ($resolutionNote !== '' ? $resolutionNote : $ticket['resolution_note']) : $ticket['resolution_note'], $ticket['id'], ]); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); 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']); } ticket_followers_notify($ticket, 'Antwort von ' . $staff['name'], (int) $staff['id']); } 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, updated_at = updated_at 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 === 'reload_mail_attachments') { $r = reload_ticket_mail_attachments($pdo, $ticket, (int) $staff['id']); if ($r['error']) { flash('error', $r['error']); } else { $n = $r['added'] + $r['inline']; $msg = ($n > 0 || $r['html'] > 0) ? ($r['added'] > 0 ? $r['added'] . ' Anhang/Anhänge' : 'Nichts als Anhang') . ($r['inline'] > 0 ? ', ' . $r['inline'] . ' Bild(er) im Text' : '') . ($r['html'] > 0 ? ' und die Formatierung' : '') . ' aus der E-Mail nachgeladen.' : 'Es gab nichts Neues zu laden.'; if ($r['existing'] > 0) $msg .= ' ' . $r['existing'] . ' war(en) schon im Ticket.'; if ($r['skipped'] > 0) $msg .= ' ' . $r['skipped'] . ' nicht übernommen (Typ nicht erlaubt oder zu groß, siehe Verlauf).'; if ($r['failed']) flash('error', $msg . ' ' . implode(' | ', $r['failed'])); else flash(($r['added'] + $r['inline'] + $r['html']) > 0 ? 'success' : 'info', $msg); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } 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 === 'snooze') { $until = ticket_snooze_until((string) ($_POST['snooze_choice'] ?? ''), (string) ($_POST['snooze_custom'] ?? '')); if ($until === null) { flash('error', 'Bitte einen Zeitpunkt in der Zukunft wählen (höchstens 1 Jahr).'); } else { ticket_snooze_set($pdo, (int) $ticket['id'], (int) $staff['id'], $until); flash('success', 'Wiedervorlage bis ' . date('d.m.Y H:i', (int) strtotime($until)) . ' Uhr. Das Ticket kommt dann zurück und du wirst benachrichtigt.'); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'unsnooze') { ticket_snooze_clear($pdo, (int) $ticket['id'], (int) $staff['id'], 'Wiedervorlage aufgehoben'); flash('success', 'Wiedervorlage aufgehoben.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'follow' || $action === 'unfollow') { try { if ($action === 'follow') { ticket_follow($pdo, (int) $ticket['id'], (int) $staff['id']); flash('success', 'Du beobachtest dieses Ticket jetzt und bekommst Benachrichtigungen bei Antworten.'); } else { ticket_unfollow($pdo, (int) $ticket['id'], (int) $staff['id']); flash('success', 'Du beobachtest dieses Ticket nicht mehr.'); } } catch (Throwable $e) { flash('error', 'Das ging nicht. Bitte unter System-Update die Datenbank aktualisieren.'); } 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, updated_at = updated_at 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, updated_at = updated_at 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 = ?, resolution_code = ?, resolution_note = ?, updated_at = ? WHERE id = ?') ->execute(['closed', $dst['id'], 'duplicate', 'Zusammengeführt mit Ticket #' . $dst['ticket_number'], $now, $src['id']]); // Ziel-Ticket aktualisieren (updated_at) $pdo->prepare('UPDATE tickets SET updated_at = ? WHERE id = ?') ->execute([$now, $dst['id']]); $pdo->commit(); ticket_sla_status_transition($pdo, (int) $src['id'], (string) $src['status'], 'closed', (int) $staff['id']); 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 === 'link_add' || $action === 'link_remove') { // Berechtigung „Tickets verknüpfen“ wurde oben geprüft if ($action === 'link_add') { $err = ticket_link_add($pdo, (int) $ticket['id'], (string) ($_POST['link_number'] ?? ''), (string) ($_POST['link_type'] ?? 'related'), (int) $staff['id'], staff_department_ids($staff)); if ($err) flash('error', $err); else { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Mit Ticket #' . ltrim(trim((string) $_POST['link_number']), '#') . ' verknüpft'); flash('success', 'Tickets verknüpft.'); } } elseif (ticket_link_remove($pdo, (int) ($_POST['link_id'] ?? 0), (int) $ticket['id'])) { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Ticketverknüpfung entfernt'); flash('success', 'Verknüpfung entfernt.'); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'problem_link') { if (!has_permission($staff, 'manage_problems')) { flash('error', 'Für das Problemmanagement fehlt die erforderliche Berechtigung.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } $problemIdToLink = (int) ($_POST['problem_id'] ?? 0); if (!ticket_problem_link($pdo, $problemIdToLink, (int) $ticket['id'], (int) $staff['id'])) flash('error', 'Problem konnte nicht gefunden werden.'); else { log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Mit ' . ticket_problem_number($problemIdToLink) . ' verknüpft'); flash('success', 'Ticket mit Problem verknüpft.'); } redirect('/staff/ticket_view.php?n=' . urlencode($number)); } if ($action === 'problem_unlink') { if (!has_permission($staff, 'manage_problems')) { flash('error', 'Für das Problemmanagement fehlt die erforderliche Berechtigung.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } $problemIdToUnlink = (int) ($_POST['problem_id'] ?? 0); ticket_problem_unlink($pdo, $problemIdToUnlink, (int) $ticket['id']); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Problemverknüpfung ' . ticket_problem_number($problemIdToUnlink) . ' entfernt'); flash('success', 'Ticket vom Problem getrennt.'); 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'); $resolutionCode = (string) ($_POST['resolution_code'] ?? $ticket['resolution_code'] ?? ''); $resolutionNote = trim(mb_substr((string) ($_POST['resolution_note'] ?? $ticket['resolution_note'] ?? ''), 0, 2000)); if ($status !== $ticket['status'] && in_array($status, ['resolved', 'closed'], true)) { if (!isset(ticket_resolution_reasons()[$resolutionCode])) { flash('error', 'Bitte beim Schließen einen Abschlussgrund auswählen.'); redirect('/staff/ticket_view.php?n=' . urlencode($number)); } } $prio = null; foreach ($priorities as $p) { if ($p['id'] == $prioId) $prio = $p; } $slaChanged = $deptId !== (int) $ticket['department_id'] || $prioId !== (int) $ticket['priority_id']; $sla = $prio && $slaChanged ? calc_sla_dates($prio, $ticket['created_at'], $deptId, !empty($ticket['help_topic_id']) ? (int) $ticket['help_topic_id'] : null) : null; if ($sla) { $priorPauses = ticket_sla_completed_pause_seconds($pdo, (int) $ticket['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) $ticket['id'], 'adjusted', 'first_response', 'Neue Frist: ' . $sla['first_response_due_at'], (int) $staff['id']); if (!empty($sla['resolution_due_at'])) ticket_sla_history_add($pdo, (int) $ticket['id'], 'adjusted', 'resolution', 'Neue Frist: ' . $sla['resolution_due_at'], (int) $staff['id']); } $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=?, resolution_code=?, resolution_note=?, 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, in_array($status, ['resolved', 'closed'], true) ? ($resolutionCode ?: $ticket['resolution_code']) : $ticket['resolution_code'], in_array($status, ['resolved', 'closed'], true) ? ($resolutionNote ?: $ticket['resolution_note']) : $ticket['resolution_note'], $now, $ticket['id'], ]); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $status, (int) $staff['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']); $slaPaused = !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']); $slaHistory = ticket_sla_history_for_ticket($pdo, $ticket); $ticketDone = in_array($ticket['status'], ['resolved','closed'], true); // erledigt: gelöst und geschlossen gelten gleich, die SLA-Uhr zählt nicht mehr $isOverdue = !$ticketDone && (!empty($ticket['is_overdue']) || (!$slaPaused && $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time())); // 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']; ?>]*)>(.*?)<\/blockquote>/is', '', $safe ); // Zitierter Verlauf früherer Mails (Outlook: Linie und Mailkopf) wird eingeklappt if (empty($msg['is_internal_note'])) { [$safeNew, $safeQuoted] = mail_split_quoted_html($safe); if ($safeQuoted !== '') $safe = $safeNew . mail_quote_wrap($safeQuoted); } ?>🌐 Bilder von fremden Webseiten sind für diese Ansicht geladen. Wieder ausblenden🔒 Bilder von fremden Webseiten wurden aus Datenschutzgründen nicht geladen. Bilder jetzt laden= $safe ?>1) { foreach ($parts as $p) { $p = trim($p); if ($p !== '') $out .= '' . nl2br(htmlspecialchars($p, ENT_QUOTES, 'UTF-8')) . '
'; } } else { $out .= '' . nl2br(htmlspecialchars($t, ENT_QUOTES, 'UTF-8')) . '
'; } return $out; }; echo $renderText($text); if ($quotedText !== '') echo mail_quote_wrap($renderText($quotedText)); ?>
Keine verknüpften Tickets.
Dieses Ticket ist keinem bekannten Problem zugeordnet.
Noch keine SLA-Ereignisse erfasst.
💤 Bis = e(date('d.m.Y H:i', (int) strtotime($snoozedUntil))) ?> Uhr ausgeblendet, dann kommt es zurück.
Das Ticket verschwindet aus den Listen und kommt zum Zeitpunkt zurück, mit Benachrichtigung. Antwortet der Kunde früher, kommt es sofort zurück.
Niemand. Beobachter bekommen Benachrichtigungen, ohne dass ihnen das Ticket zugewiesen ist.
Erhalten eine Kopie jeder öffentlichen Antwort per E-Mail.
Noch keine Zeit erfasst.
"= e($ticketRating['comment']) ?>"
Noch keine Bewertung abgegeben.