Files
deskhub/DeskHub-Webseite/staff/ticket_view.php
T
2026-11-01 11:15:52 +00:00

1918 lines
115 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/ticket_extras.php'; // falls bootstrap.php noch die alte Version ist
require_once __DIR__ . '/../includes/imap_spam.php';
$staff = require_staff_login();
$pdo = get_db();
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
$number = $_GET['n'] ?? '';
$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 (!$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)
// 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']);
$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();
$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') $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;
$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 === '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 === 'problem_link') {
if ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Tickets mit Problemen verknüpfen.'); 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 ($staff['role'] !== 'admin') { flash('error', 'Nur Administratoren dürfen Problem-Verknüpfungen ändern.'); 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);
$isOverdue = !empty($ticket['is_overdue']) || (!$slaPaused && $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();
}
?>
<!-- Präsenz-Banner -->
<div id="presence-banner" style="display:none; background:#fff8e1; border:1px solid #f59e0b; border-radius:8px; padding:9px 16px; margin-bottom:12px; font-size:.85rem; color:#92400e; display:flex; align-items:center; gap:10px">
<span>👁️</span>
<span id="presence-text"></span>
</div>
<?php
$sourceLabels = ['web'=>'🌐 Web','email'=>'📧 E-Mail','phone'=>'📞 Telefon','staff'=>'🖥 Intern','chat'=>'💬 Live-Chat'];
$sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
?>
<!-- Ticket-Header wie osTicket -->
<div style="background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:16px;overflow:hidden">
<!-- Titelzeile -->
<div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div>
<h2 style="margin:0 0 4px;font-size:1.15rem"><?= e($ticket['subject']) ?></h2>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<span class="ticket-no">#<?= e($ticket['ticket_number']) ?></span>
<span class="badge" style="background:<?= $statusColor ?>"><?= e($statusLabel) ?></span>
<?php if (!empty($ticket['is_spam'])): ?><span class="badge" style="background:#dc2626">🚫 SPAM</span><?php endif; ?>
<span class="badge" style="background:<?= e($ticket['priority_color']) ?>"><?= e($ticket['priority_name']) ?></span>
<?php if ($isOverdue): ?><span class="badge" style="background:#dc2626">ÜBERFÄLLIG</span><?php endif; ?>
<?php if ($myWorkSessionStartedAt): ?>
<span class="muted" style="font-size:.78rem" title="Seit <?= e(format_dt($myWorkSessionStartedAt)) ?> geöffnet – wird beim Antworten automatisch als Zeit erfasst.">⏱ Zeiterfassung läuft</span>
<?php endif; ?>
<span class="muted" style="font-size:.78rem"><?= $sourceLabel ?></span>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<?php if (($ticket['source'] ?? '') === 'email' && empty($ticket['is_spam'])): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Dieses Ticket als Spam markieren und die zugehörige E-Mail im Postfach nach Spam/Junk verschieben?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="mark_spam">
<button type="submit" class="btn" style="font-size:.8rem;padding:5px 12px;background:#dc2626;color:#fff">🚫 Als Spam markieren</button>
</form>
<?php elseif (!empty($ticket['is_spam'])): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Spam-Markierung aufheben und die E-Mail zurück ins Postfach verschieben?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="restore_spam">
<button type="submit" class="btn secondary" style="font-size:.8rem;padding:5px 12px">↩️ Spam-Markierung aufheben</button>
</form>
<?php endif; ?>
<a href="/staff/ticket_print.php?n=<?= urlencode($ticket['ticket_number']) ?>" target="_blank" class="btn secondary" style="font-size:.8rem;padding:5px 12px">🖨 Drucken</a>
</div>
</div>
<!-- Info-Tabelle -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0;border-bottom:1px solid var(--border)">
<div style="padding:12px 18px;border-right:1px solid var(--border)">
<table style="width:100%;font-size:.83rem;border-collapse:collapse">
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Absender:</td>
<td><?= e($ticket['requester_name']) ?> &lt;<?= e($ticket['requester_email']) ?>&gt;</td>
</tr>
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Erstellt:</td>
<td><?= format_dt($ticket['created_at']) ?></td>
</tr>
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Abteilung:</td>
<td><?= e($ticket['department_name']) ?></td>
</tr>
<?php if ($helpTopicName): ?>
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Hilfethema:</td>
<td><?= e($helpTopicName) ?></td>
</tr>
<?php endif; ?>
</table>
</div>
<div style="padding:12px 18px">
<table style="width:100%;font-size:.83rem;border-collapse:collapse">
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Status:</td>
<td><span class="badge" style="background:<?= $statusColor ?>"><?= e($statusLabel) ?></span></td>
</tr>
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Priorität:</td>
<td><?= e($ticket['priority_name']) ?></td>
</tr>
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Zugewiesen:</td>
<td><?= e($ticket['staff_name'] ?? '– nicht zugewiesen –') ?></td>
</tr>
<?php if ($ticket['resolution_due_at']): ?>
<tr>
<td style="color:var(--muted);padding:3px 10px 3px 0;white-space:nowrap;font-weight:600">Fällig:</td>
<td style="color:<?= $isOverdue?'#dc2626':'inherit' ?>"><?= format_dt($ticket['resolution_due_at']) ?></td>
</tr>
<?php endif; ?>
</table>
</div>
</div>
</div>
<?php foreach ($errors as $err): ?>
<div class="flash error"><?= e($err) ?></div>
<?php endforeach; ?>
<?php if ($lockedByOther): ?>
<?php /* Dieser Block bleibt als Fallback für POST-Requests, wird aber bei GET nicht mehr erreicht */ ?>
<?php endif; ?>
<?php if ($adminLockedBy): ?>
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
🔒 Dieses Ticket wird gerade von <strong>&bdquo;<?= e($adminLockedBy) ?>&ldquo;</strong> bearbeitet. Als Administrator kannst du es trotzdem lesen und bearbeiten.
<form method="post" style="margin:0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="admin_force_unlock">
<input type="hidden" name="locked_by_name" value="<?= e($adminLockedBy) ?>">
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre aufheben und übernehmen</button>
</form>
</div>
<?php endif; ?>
<?php if ($mergedIntoBanner): ?>
<div style="background:#fef3c7;border:1px solid #f59e0b;border-radius:8px;padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:10px">
<span style="font-size:1.1rem">⚠️</span>
<div style="font-size:.85rem">
<strong>Dieses Ticket wurde zusammengeführt.</strong>
Der Verlauf wurde in
<a href="/staff/ticket_view.php?n=<?= e($mergedIntoBanner) ?>" style="font-weight:700;color:#92400e">#<?= e($mergedIntoBanner) ?></a>
verschoben. Dieses Ticket ist jetzt schreibgeschützt.
</div>
</div>
<?php endif; ?>
<?php if ($mergedFrom): ?>
<div style="background:#f0fdf4;border:1px solid #22c55e;border-radius:8px;padding:12px 16px;margin-bottom:16px">
<div style="font-size:.8rem;font-weight:600;color:#15803d;margin-bottom:6px">📥 Zusammengeführte Tickets</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<?php foreach ($mergedFrom as $mf): ?>
<a href="/staff/ticket_view.php?n=<?= e($mf['ticket_number']) ?>"
style="font-size:.78rem;padding:3px 10px;background:#dcfce7;border:1px solid #86efac;border-radius:20px;text-decoration:none;color:#15803d">
#<?= e($mf['ticket_number']) ?> – <?= e(mb_strimwidth($mf['subject'], 0, 40, '…')) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div style="display:grid;grid-template-columns:1fr 320px;gap:20px;align-items:flex-start">
<div style="min-width:0">
<?php if ($ticket['source'] === 'chat'):
// Live-Chat-Tickets bestehen oft aus vielen kurzen Hin-und-Her-Nachrichten – als
// einzelne, große Ticket-Antwort-Karten (wie bei E-Mail-Tickets üblich) wirkt das
// unübersichtlich. Stattdessen: ein kompaktes Chatprotokoll in einer Box. Interne
// Notizen sind davon ausgenommen und bleiben unten als normale Karten sichtbar –
// die eigentliche Nachrichtenschleife überspringt unten nur die öffentlichen
// Chat-Nachrichten, die hier schon gezeigt werden.
$chatMsgs = array_filter($thread, fn($m) => !$m['is_internal_note']);
?>
<div class="card" style="margin-bottom:20px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px">💬 Live-Chat-Protokoll</div>
<div style="display:flex;flex-direction:column;gap:12px;max-height:560px;overflow-y:auto;padding:2px 4px">
<?php foreach ($chatMsgs as $m):
$isStaffMsg = $m['author_type'] === 'staff';
$who = $isStaffMsg ? ($m['staff_name'] ?? 'Mitarbeiter') : ($m['user_name'] ?? $ticket['requester_name']);
$body = (string) ($m['message'] ?? '');
$bodyIsHtml = preg_match('/<[a-zA-Z][^>]*>/i', $body);
$attStmt->execute([$m['id']]);
$msgAttachments = $attStmt->fetchAll();
?>
<div style="display:flex;flex-direction:column;align-items:<?= $isStaffMsg ? 'flex-end' : 'flex-start' ?>">
<div style="font-size:.68rem;color:var(--muted);margin:0 4px 3px;font-weight:600">
<?= e($who) ?> <span style="font-weight:400">· <?= format_dt($m['created_at']) ?></span>
</div>
<div style="max-width:75%;padding:8px 12px;border-radius:11px;font-size:.85rem;line-height:1.6;word-break:break-word;
<?= $isStaffMsg ? 'background:var(--primary);color:#fff' : 'background:var(--bg-soft);border:1px solid var(--border);color:var(--ink)' ?>">
<?php if ($bodyIsHtml): ?>
<?= sanitize_staff_html($body) ?>
<?php else: ?>
<?= nl2br(e($body)) ?>
<?php endif; ?>
</div>
<?php if ($msgAttachments): ?>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:5px">
<?php foreach ($msgAttachments as $a): ?>
<a href="/download.php?id=<?= $a['id'] ?>" style="font-size:.72rem;padding:3px 9px;background:var(--bg-soft);border:1px solid var(--border);border-radius:12px;text-decoration:none;color:var(--ink)">📎 <?= e($a['original_name']) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if (!$chatMsgs): ?>
<div style="font-size:.82rem;color:var(--muted)">Noch keine Nachrichten.</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php foreach ($thread as $msg):
if ($ticket['source'] === 'chat' && !$msg['is_internal_note']) continue; // s.o., schon im Chatprotokoll gezeigt
$attStmt->execute([$msg['id']]);
$attachments = $attStmt->fetchAll();
$isStaff = $msg['author_type'] === 'staff';
$authorName = $isStaff
? ($msg['staff_name'] ?? 'Mitarbeiter')
: ($msg['user_name'] ?? $ticket['requester_name']);
?>
<div class="thread-msg <?= $isStaff ? 'from-staff' : '' ?> <?= $msg['is_internal_note'] ? 'is-note' : '' ?>" id="msg-<?= $msg['id'] ?>">
<!-- Header: Absender + Datum + Betreff -->
<div class="meta">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<div style="width:34px;height:34px;border-radius:50%;background:<?= $isStaff ? 'var(--primary)' : '#64748b' ?>;color:#fff;display:flex;align-items:center;justify-content:center;font-size:.85rem;font-weight:700;flex-shrink:0">
<?= mb_strtoupper(mb_substr($authorName, 0, 1)) ?>
</div>
<div>
<div style="font-weight:700;font-size:.88rem;color:var(--ink)">
<?= e($authorName) ?>
<?php if ($msg['is_internal_note']): ?>
<span class="note-flag">Interne Notiz</span>
<?php endif; ?>
</div>
<div style="font-size:.75rem;color:var(--muted)"><?= format_dt($msg['created_at']) ?></div>
</div>
</div>
<?php if (!$isStaff && $ticket['subject']): ?>
<div style="font-size:.78rem;color:var(--muted);font-style:italic"><?= e($ticket['subject']) ?></div>
<?php endif; ?>
</div>
<!-- Nachrichteninhalt -->
<div class="body">
<?php
$msgBody = $msg['message'] ?? '';
?>
<?php
// Quoting-Blöcke einklappen (> ... Linien)
$isHtml = preg_match('/<[a-zA-Z][^>]*>/i', $msgBody);
$msgId = $msg['id'];
?>
<!-- Quelltext-Toggle -->
<div style="display:flex;justify-content:flex-end;gap:6px;margin-bottom:8px">
<button type="button" onclick="toggleRaw(<?= $msgId ?>)"
id="raw-btn-<?= $msgId ?>"
style="font-size:.72rem;padding:3px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-soft);color:var(--muted);cursor:pointer">
Quelltext
</button>
</div>
<!-- Quelltext (versteckt) -->
<pre id="raw-<?= $msgId ?>" style="display:none;white-space:pre-wrap;word-break:break-all;font-size:.78rem;background:#1e293b;color:#e2e8f0;padding:14px;border-radius:6px;overflow-x:auto;line-height:1.6"><?= htmlspecialchars($msgBody, ENT_QUOTES, 'UTF-8') ?></pre>
<!-- Gerenderter Inhalt -->
<div id="rendered-<?= $msgId ?>">
<?php if ($isHtml): ?>
<?php
// HTML-E-Mail sicher rendern
$safe = sanitize_staff_html($msgBody);
// Zitat-Blöcke einklappbar machen
$safe = preg_replace(
'/<blockquote([^>]*)>(.*?)<\/blockquote>/is',
'<div class="email-quote-wrap"><button type="button" class="email-quote-toggle" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.nextElementSibling.style.display===\'none\'?\'▶ Zitat anzeigen\':\'▼ Zitat ausblenden\'">▶ Zitat anzeigen</button><blockquote\1 style="display:none">\2</blockquote></div>',
$safe
);
?>
<div class="email-html-body" style="font-size:.87rem;line-height:1.7;overflow-x:auto">
<?= $safe ?>
</div>
<?php else: ?>
<?php
$text = trim($msgBody);
$text = str_replace(["\r\n", "\r"], "\n", $text);
$parts = preg_split('/\n{2,}/', $text);
if (count($parts) > 1) {
foreach ($parts as $p) {
$p = trim($p);
if ($p !== '') echo '<p style="margin:0 0 12px;line-height:1.7">' . nl2br(htmlspecialchars($p, ENT_QUOTES, 'UTF-8')) . '</p>';
}
} else {
echo '<p style="margin:0;line-height:1.7">' . nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8')) . '</p>';
}
?>
<?php endif; ?>
</div>
</div>
<?php if ($attachments): ?>
<div style="padding:10px 16px 14px;border-top:1px solid var(--border-soft)">
<!-- Bild-Vorschauen -->
<?php
$imgExts = ['jpg','jpeg','png','gif','webp','svg','bmp'];
$imgAtts = array_filter($attachments, fn($a) => in_array(strtolower(pathinfo($a['original_name'],PATHINFO_EXTENSION)), $imgExts));
$otherAtts = array_filter($attachments, fn($a) => !in_array(strtolower(pathinfo($a['original_name'],PATHINFO_EXTENSION)), $imgExts));
?>
<?php if ($imgAtts): ?>
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px">
<?php foreach ($imgAtts as $a): ?>
<a href="/download.php?id=<?= $a['id'] ?>&preview=1" target="_blank"
title="<?= e($a['original_name']) ?>"
style="display:block;border:1px solid var(--border);border-radius:6px;overflow:hidden;flex-shrink:0">
<img src="/download.php?id=<?= $a['id'] ?>&preview=1"
alt="<?= e($a['original_name']) ?>"
style="max-width:200px;max-height:150px;display:block;object-fit:cover">
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Datei-Downloads -->
<?php if ($otherAtts): ?>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<?php foreach ($otherAtts as $a):
$ext = strtolower(pathinfo($a['original_name'], PATHINFO_EXTENSION));
$icon = match(true) {
$ext === 'pdf' => '📄',
in_array($ext,['doc','docx']) => '📝',
in_array($ext,['xls','xlsx','csv']) => '📊',
in_array($ext,['zip','rar','tar','gz']) => '📦',
in_array($ext,['mp4','mov','avi','mkv'])=> '🎥',
in_array($ext,['mp3','wav','ogg']) => '🎵',
default => '📎',
};
$sizeFmt = $a['filesize'] > 1048576
? round($a['filesize']/1048576,1).' MB'
: round($a['filesize']/1024).' KB';
?>
<a href="/download.php?id=<?= $a['id'] ?>"
style="display:flex;align-items:center;gap:6px;font-size:.8rem;padding:5px 10px;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;text-decoration:none;color:var(--ink)">
<?= $icon ?>
<span><?= e($a['original_name']) ?></span>
<span style="color:var(--muted);font-size:.72rem"><?= $sizeFmt ?></span>
<?php if ($ext === 'pdf'): ?>
<a href="/download.php?id=<?= $a['id'] ?>&preview=1" target="_blank"
style="font-size:.7rem;color:var(--primary);margin-left:4px;text-decoration:none">Öffnen</a>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
// Kommentare laden
$commentStmt->execute([$msg['id']]);
$threadComments = $commentStmt->fetchAll();
?>
<?php if ($threadComments || !$msg['is_internal_note']): ?>
<div class="thread-comments" style="border-top:1px solid var(--border-soft);background:var(--bg-soft)">
<!-- Vorhandene Kommentare -->
<?php foreach ($threadComments as $co): ?>
<div style="display:flex;gap:12px;margin:12px 16px;padding:12px 16px;background:#eef2ff;border:1px solid #c7d2fe;border-left:4px solid #6366f1;border-radius:10px">
<div style="width:34px;height:34px;border-radius:50%;background:#6366f1;color:#fff;display:flex;align-items:center;justify-content:center;font-size:.85rem;font-weight:700;flex-shrink:0">
<?= mb_strtoupper(mb_substr($co['staff_name'], 0, 1)) ?>
</div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">
<span style="font-size:.9rem;font-weight:700;color:#1e1b4b"><?= e($co['staff_name']) ?></span>
<span style="font-size:.66rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#4338ca;background:#e0e7ff;border-radius:6px;padding:2px 7px">Kommentar</span>
<span style="font-size:.75rem;color:var(--muted)"><?= format_dt($co['created_at']) ?></span>
<?php if ($co['staff_id'] === $staff['id'] || $staff['role'] === 'admin'): ?>
<form method="post" style="margin-left:auto">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_comment">
<input type="hidden" name="comment_id" value="<?= $co['id'] ?>">
<button type="submit" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:.72rem;padding:0" title="Kommentar löschen" onclick="return confirm('Kommentar löschen?')">✕</button>
</form>
<?php endif; ?>
</div>
<div style="font-size:.92rem;color:var(--ink);line-height:1.55"><?= nl2br(e($co['message'])) ?></div>
</div>
</div>
<?php endforeach; ?>
<!-- Kommentar hinzufügen -->
<div class="comment-input-wrap" id="comment-wrap-<?= $msg['id'] ?>" style="display:none;padding:8px 16px">
<form method="post" style="display:flex;gap:8px;align-items:flex-start;flex:1 1 100%;width:100%;min-width:0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="add_comment">
<input type="hidden" name="thread_id" value="<?= $msg['id'] ?>">
<div style="width:26px;height:26px;border-radius:50%;background:#6366f1;color:#fff;display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:700;flex-shrink:0;margin-top:2px">
<?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?>
</div>
<textarea name="comment_message" placeholder="Interner Kommentar…" required
rows="3" style="flex:1 1 auto;width:100%;min-width:0;min-height:90px;resize:vertical;font-size:.88rem;padding:8px 10px"
onkeydown="if(event.ctrlKey&&event.key==='Enter'){this.closest('form').submit()}"></textarea>
<div style="display:flex;flex-direction:column;gap:4px">
<button type="submit" class="btn" style="font-size:.78rem;padding:5px 10px;white-space:nowrap">Senden</button>
<button type="button" class="btn secondary" style="font-size:.78rem;padding:5px 10px" onclick="toggleComment(<?= $msg['id'] ?>)">Abbrechen</button>
</div>
</form>
</div>
<!-- Toggle-Button -->
<div style="padding:5px 16px">
<button type="button" onclick="toggleComment(<?= $msg['id'] ?>)" id="comment-btn-<?= $msg['id'] ?>"
style="background:none;border:none;cursor:pointer;font-size:.76rem;color:var(--muted);padding:0;display:flex;align-items:center;gap:4px">
<span style="font-size:.9rem">💬</span>
<?= $threadComments ? count($threadComments) . ' Kommentar' . (count($threadComments) !== 1 ? 'e' : '') . ' · ' : '' ?>
Kommentieren
</button>
</div>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<div class="card">
<h3>Antworten / Notiz</h3>
<?php
$sigOwn = trim((string) ($staff['signature'] ?? ''));
$sigDeptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$sigDeptStmt->execute([$ticket['department_id']]);
$sigDept = trim((string) $sigDeptStmt->fetchColumn());
$sigDefault = $sigOwn !== '' ? 'own' : ($sigDept !== '' ? 'dept' : 'none');
$sigHtml = ['own' => $sigOwn !== '' ? sanitize_staff_html($sigOwn) : '', 'dept' => $sigDept !== '' ? sanitize_staff_html($sigDept) : '', 'none' => ''];
?>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="action" value="reply" id="form-action">
<?php if ($cannedResponses): ?>
<?php
// Variablen-Daten für JS vorbereiten
$cannedVars = [
'kundename' => $ticket['requester_name'] ?? '',
'vorname' => explode(' ', trim($ticket['requester_name'] ?? ''))[0] ?? '',
'email' => $ticket['requester_email'] ?? '',
'ticketnummer' => '#' . ($ticket['ticket_number'] ?? ''),
'betreff' => $ticket['subject'] ?? '',
'abteilung' => $ticket['department_name'] ?? '',
'prioritaet' => $ticket['priority_name'] ?? '',
'status' => $ticket['status'] ?? '',
'mitarbeiter' => $ticket['staff_name'] ?? $staff['name'] ?? '',
'datum' => date('d.m.Y'),
'uhrzeit' => date('H:i'),
'signatur' => $staff['signature'] ?? '',
];
?>
<script>window.cannedVars = <?= json_encode($cannedVars, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;</script>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
<select id="canned_picker" style="flex:1" onchange="applyCannedResponse(this)">
<option value="">– Vorlage wählen –</option>
<?php
$cannedOwn = array_filter($cannedResponses, fn($cr) => !empty($cr['department_id']));
$cannedAll = array_filter($cannedResponses, fn($cr) => empty($cr['department_id']));
foreach ([['Abteilung „' . ($ticket['department_name'] ?? '') . '“', $cannedOwn], ['Für alle Abteilungen', $cannedAll]] as [$groupLabel, $groupItems]):
if (!$groupItems) continue; ?>
<optgroup label="<?= e($groupLabel) ?>">
<?php foreach ($groupItems as $cr): ?>
<option value="<?= $cr['id'] ?>" data-body="<?= e($cr['body']) ?>"><?= e($cr['title']) ?></option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
<button type="button" id="canned-preview-btn" style="display:none;font-size:.78rem;padding:5px 10px" class="btn secondary" onclick="toggleCannedPreview()">Vorschau</button>
</div>
<div id="canned-preview" style="display:none;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:12px;font-size:.84rem;line-height:1.7;margin-bottom:8px;max-height:200px;overflow-y:auto"></div>
<?php endif; ?>
<textarea name="message" id="message-field" data-richtext placeholder="Nachricht eingeben..." required></textarea>
<div id="reply-signature" style="margin:14px 0">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px">
<label for="signature_choice" style="margin:0">Signatur</label>
<select name="signature_choice" id="signature_choice" style="width:auto;min-width:260px">
<option value="own" <?= $sigDefault === 'own' ? 'selected' : '' ?> <?= $sigOwn === '' ? 'disabled' : '' ?>>Meine Signatur<?= $sigOwn === '' ? ' (nicht hinterlegt)' : '' ?></option>
<option value="dept" <?= $sigDefault === 'dept' ? 'selected' : '' ?> <?= $sigDept === '' ? 'disabled' : '' ?>>Abteilung „<?= e($ticket['department_name']) ?>“<?= $sigDept === '' ? ' (nicht hinterlegt)' : '' ?></option>
<option value="none" <?= $sigDefault === 'none' ? 'selected' : '' ?>>Keine Signatur</option>
</select>
<a href="/staff/profile.php?tab=signature" class="muted" style="font-size:.78rem">Meine Signatur bearbeiten</a>
</div>
<div id="signature-preview" style="border:1px dashed var(--border);border-radius:8px;padding:12px 14px;font-size:.88rem;line-height:1.6;color:var(--muted);background:var(--bg-soft,transparent);min-height:20px"></div>
<div class="muted" style="font-size:.76rem;margin-top:4px">Wird nur an öffentliche Antworten angehängt, nicht an interne Notizen.</div>
</div>
<script>
(function () {
var sigs = <?= json_encode($sigHtml, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;
var sel = document.getElementById('signature_choice');
var box = document.getElementById('signature-preview');
function show() {
var html = sigs[sel.value] || '';
if (html) { box.innerHTML = html; box.style.opacity = '1'; }
else { box.textContent = sel.value === 'none' ? 'Es wird keine Signatur angehängt.' : 'Keine Signatur hinterlegt.'; box.style.opacity = '.7'; }
}
sel.addEventListener('change', show);
show();
})();
</script>
<div class="field-row">
<div>
<label for="attachments">Anhänge (optional, mehrere möglich)</label>
<input type="file" id="attachments" name="attachments[]" multiple>
</div>
<div>
<label for="set_status">Status nach dem Senden</label>
<select name="set_status" id="set_status" onchange="syncReplyResolutionFields()">
<option value="answered">Beantwortet</option>
<option value="pending">Wartet auf Kunde</option>
<option value="resolved">Gelöst</option>
<option value="closed">Geschlossen</option>
</select>
</div>
</div>
<div id="reply-resolution-fields" style="display:none;margin-top:10px;padding:10px;border:1px solid var(--border-soft);border-radius:8px">
<label for="reply_resolution_code">Abschlussgrund <span style="color:var(--danger)">*</span></label>
<select id="reply_resolution_code" name="resolution_code"><?php foreach ($resolutionReasons as $code => $label): ?><option value="<?= e($code) ?>" <?= $code === 'fixed' ? 'selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?></select>
<label for="reply_resolution_note" style="margin-top:7px">Lösungshinweis (optional)</label><textarea id="reply_resolution_note" name="resolution_note" rows="2" maxlength="2000" placeholder="Kurze Notiz zur Lösung"></textarea>
</div>
<script>
function syncReplyResolutionFields(){var status=document.getElementById('set_status'),box=document.getElementById('reply-resolution-fields'),reason=document.getElementById('reply_resolution_code');if(!status||!box||!reason)return;var required=['resolved','closed'].includes(status.value);box.style.display=required?'block':'none';reason.required=required;}
syncReplyResolutionFields();
</script>
<div class="btn-row">
<button type="submit" class="btn" onclick="document.getElementById('form-action').value='reply'">Öffentliche Antwort senden</button>
<button type="submit" class="btn secondary" onclick="document.getElementById('form-action').value='note'">Nur interne Notiz speichern</button>
</div>
</form>
</div>
<?php if ($activity): ?>
<div class="card">
<h3>Aktivität</h3>
<?php foreach ($activity as $log): ?>
<div class="muted" style="font-size:.82rem;padding:4px 0"><?= format_dt($log['created_at']) ?> — <?= e($log['staff_name'] ?? 'System') ?>: <span><?= e($log['action']) ?></span></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<div style="margin-top:20px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;align-items:start">
<nav class="tv-tabs" id="tv-tabs" role="tablist" aria-label="Ticket-Details">
<button type="button" role="tab" class="tv-tab active" data-tab="details">Details</button>
<button type="button" role="tab" class="tv-tab" data-tab="manage">Organisation</button>
<button type="button" role="tab" class="tv-tab" data-tab="more">Mehr</button>
</nav>
<!-- Ticket-Eigenschaften als horizontale Leiste -->
<div class="card" data-ptab="details" style="padding:14px 20px">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; padding-bottom:10px; border-bottom:1px solid var(--border-soft)">
<span style="font-size:.82rem; font-weight:700; color:var(--ink)">Eigenschaften</span>
<?php if (!$lockedByOther): ?>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="release_lock">
<button type="submit" title="Sperre aufheben" style="background:none;border:none;cursor:pointer;font-size:.75rem;color:var(--muted);padding:0">🔓 freigeben</button>
</form>
<?php endif; ?>
</div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="action" value="update_props">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px">
<?php foreach (['status'=>['Offen'=>'open','Wartet auf Kunde'=>'pending','Beantwortet'=>'answered','Pausiert'=>'on_hold','Gelöst'=>'resolved','Geschlossen'=>'closed']] as $k=>$opts): ?>
<div>
<label for="status" style="margin:0 0 4px; font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Status</label>
<select name="status" id="status" onchange="syncTicketResolutionFields()" style="font-size:.875rem; padding:7px 10px">
<?php foreach (['open'=>'Offen','pending'=>'Wartet auf Kunde','answered'=>'Beantwortet','on_hold'=>'Pausiert','resolved'=>'Gelöst','closed'=>'Geschlossen'] as $val=>$lbl): ?>
<option value="<?= $val ?>" <?= $ticket['status']===$val?'selected':'' ?>><?= $lbl ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endforeach; ?>
<div id="ticket-resolution-fields">
<label for="resolution_code" style="margin:0 0 4px;font-size:.71rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Abschlussgrund</label>
<select name="resolution_code" id="resolution_code" data-ticket-was-terminal="<?= in_array($ticket['status'], ['resolved', 'closed'], true) ? '1' : '0' ?>" style="font-size:.875rem;padding:7px 10px"><option value="" <?= empty($ticket['resolution_code']) ? 'selected' : '' ?>>Grund auswählen</option><?php foreach ($resolutionReasons as $code => $label): ?><option value="<?= e($code) ?>" <?= ($ticket['resolution_code'] ?? '') === $code ? 'selected' : '' ?>><?= e($label) ?></option><?php endforeach; ?></select>
<label for="resolution_note" style="margin:7px 0 4px;font-size:.71rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Lösungshinweis (optional)</label>
<input type="text" name="resolution_note" id="resolution_note" maxlength="2000" value="<?= e((string) ($ticket['resolution_note'] ?? '')) ?>" placeholder="Kurze Notiz zur Lösung">
</div>
<div>
<label for="priority_id" style="margin:0 0 4px; font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Priorität</label>
<select name="priority_id" id="priority_id" style="font-size:.875rem; padding:7px 10px">
<?php foreach ($priorities as $p): ?>
<option value="<?= $p['id'] ?>" <?= $ticket['priority_id']==$p['id']?'selected':'' ?>><?= e($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="department_id" style="margin:0 0 4px; font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Abteilung</label>
<select name="department_id" id="department_id" style="font-size:.875rem; padding:7px 10px">
<?php foreach ($departments as $d): ?>
<?php if ($myDeptIds !== null && !in_array((int) $d['id'], $myDeptIds, true) && $d['id'] != $ticket['department_id']) continue; ?>
<option value="<?= $d['id'] ?>" <?= $ticket['department_id']==$d['id']?'selected':'' ?>><?= e($d['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="assigned_staff_id" style="margin:0 0 4px; font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Zugewiesen an</label>
<select name="assigned_staff_id" id="assigned_staff_id" style="font-size:.875rem; padding:7px 10px">
<option value="">– nicht zugewiesen –</option>
<?php foreach ($agents as $a): ?>
<option value="<?= $a['id'] ?>" <?= $ticket['assigned_staff_id']==$a['id']?'selected':'' ?>><?= e($a['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($teams): ?>
<div>
<label for="team_id" style="margin:0 0 4px; font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Team</label>
<select name="team_id" id="team_id" style="font-size:.875rem; padding:7px 10px">
<option value="">– kein Team –</option>
<?php foreach ($teams as $tm): ?>
<option value="<?= $tm['id'] ?>" <?= ($ticket['team_id'] ?? '') == $tm['id'] ? 'selected' : '' ?>><?= e($tm['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
</div>
<button type="submit" class="btn" style="margin-top:12px">Speichern</button>
</form>
<script>
function syncTicketResolutionFields(){var status=document.getElementById('status'),reason=document.getElementById('resolution_code');if(reason)reason.required=!!status&&['resolved','closed'].includes(status.value)&&reason.dataset.ticketWasTerminal!=='1';}
syncTicketResolutionFields();
</script>
</div>
<div class="card" data-ptab="manage" style="padding:14px 20px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="font-size:.71rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)">Zugehörige Probleme</span><?php if ($staff['role'] === 'admin'): ?><a class="btn secondary" style="padding:3px 8px;font-size:.75rem" href="/staff/problems.php">Verwalten</a><?php endif; ?></div>
<?php if ($ticketProblems): foreach ($ticketProblems as $problemItem): ?>
<div style="padding:8px 0;border-top:1px solid var(--border-soft);margin-top:8px"><a href="/staff/problems.php?id=<?= (int) $problemItem['id'] ?>"><strong><?= e(ticket_problem_number((int) $problemItem['id'])) ?> · <?= e($problemItem['title']) ?></strong></a><div class="muted" style="font-size:.78rem;margin-top:3px"><?= e(ticket_problem_statuses()[$problemItem['status']] ?? $problemItem['status']) ?></div><?php if (!empty($problemItem['workaround'])): ?><div style="font-size:.82rem;margin-top:5px"><strong>Workaround:</strong> <?= nl2br(e((string) $problemItem['workaround'])) ?></div><?php endif; ?><?php if ($staff['role'] === 'admin'): ?><form method="post" style="margin-top:6px"><?= csrf_field() ?><input type="hidden" name="action" value="problem_unlink"><input type="hidden" name="problem_id" value="<?= (int) $problemItem['id'] ?>"><button class="btn secondary" type="submit" style="padding:2px 8px;font-size:.75rem">Trennen</button></form><?php endif; ?></div>
<?php endforeach; else: ?><p class="muted" style="font-size:.84rem">Dieses Ticket ist keinem bekannten Problem zugeordnet.</p><?php endif; ?>
<?php if ($staff['role'] === 'admin' && $openProblems): ?><form method="post" style="display:flex;gap:6px;align-items:end;margin-top:8px"><?= csrf_field() ?><input type="hidden" name="action" value="problem_link"><div style="flex:1;min-width:150px"><label style="font-size:.75rem">Mit Problem verknüpfen</label><select name="problem_id" required><?php foreach ($openProblems as $problemOption): ?><option value="<?= (int) $problemOption['id'] ?>"><?= e(ticket_problem_number((int) $problemOption['id']) . ' · ' . $problemOption['title']) ?></option><?php endforeach; ?></select></div><button class="btn secondary" type="submit">Verknüpfen</button></form><?php endif; ?>
</div>
<!-- SLA + Individuelle Felder -->
<div class="card" data-ptab="details" style="padding:16px 20px">
<div style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:10px">SLA-Fristen</div>
<?php if ($slaPaused): ?><div style="padding:8px 10px;margin-bottom:12px;border-radius:7px;background:#f1f5f9;color:#475569;font-size:.82rem"><strong>SLA pausiert</strong> seit <?= e(format_dt((string) $ticket['sla_paused_at'])) ?> – die Fristen laufen bei „<?= e($statusLabel) ?>“ nicht weiter.</div><?php endif; ?>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px">
<div>
<div style="font-size:.7rem; color:var(--muted-lt); margin-bottom:2px">Erstreaktion</div>
<?php $responseOverdue = !$slaPaused && $ticket['first_response_due_at'] && !$ticket['first_responded_at'] && strtotime($ticket['first_response_due_at']) < time(); ?>
<div style="font-size:.8rem; font-weight:700; color:<?= $responseOverdue ? 'var(--danger)' : 'var(--ink)' ?>"><?= format_dt($ticket['first_response_due_at']) ?></div>
<div style="font-size:.74rem; color:<?= $responseOverdue ? 'var(--danger)' : 'var(--muted)' ?>"><?= $ticket['first_responded_at'] ? '✓ beantwortet' : ($slaPaused ? 'Pausiert' : time_until($ticket['first_response_due_at'])) ?></div>
</div>
<div>
<div style="font-size:.7rem; color:var(--muted-lt); margin-bottom:2px">Lösung</div>
<?php $resolutionOverdue = !$slaPaused && $ticket['resolution_due_at'] && !$ticket['resolved_at'] && strtotime($ticket['resolution_due_at']) < time(); ?>
<div style="font-size:.8rem; font-weight:700; color:<?= $resolutionOverdue ? 'var(--danger)' : 'var(--ink)' ?>"><?= format_dt($ticket['resolution_due_at']) ?></div>
<div style="font-size:.74rem; color:<?= $resolutionOverdue ? 'var(--danger)' : 'var(--muted)' ?>"><?= $ticket['resolved_at'] ? '✓ gelöst' : ($slaPaused ? 'Pausiert' : time_until($ticket['resolution_due_at'])) ?></div>
</div>
</div>
<details style="margin-top:12px;border-top:1px solid var(--border-soft);padding-top:9px">
<summary style="cursor:pointer;font-size:.8rem;font-weight:700">SLA-Verlauf</summary>
<?php if ($slaHistory): ?><div style="display:grid;gap:6px;margin-top:8px">
<?php foreach ($slaHistory as $slaEvent):
$eventLabels = ['started' => 'Frist gestartet', 'adjusted' => 'Frist angepasst', 'paused' => 'Frist pausiert', 'resumed' => 'Frist fortgesetzt', 'overdue' => 'Frist überschritten'];
$deadlineLabels = ['first_response' => 'Erstreaktion', 'resolution' => 'Lösung'];
?>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;font-size:.76rem"><time class="muted" style="white-space:nowrap"><?= e(format_dt((string) $slaEvent['created_at'])) ?></time><strong><?= e($eventLabels[$slaEvent['event_type']] ?? $slaEvent['event_type']) ?><?= !empty($slaEvent['deadline_type']) ? ' · ' . e($deadlineLabels[$slaEvent['deadline_type']] ?? $slaEvent['deadline_type']) : '' ?></strong><span class="muted"><?= e((string) ($slaEvent['details'] ?? '')) ?><?= !empty($slaEvent['staff_name']) ? ' · ' . e($slaEvent['staff_name']) : '' ?></span></div>
<?php endforeach; ?>
</div><?php else: ?><p class="muted" style="font-size:.78rem">Noch keine SLA-Ereignisse erfasst.</p><?php endif; ?>
</details>
<?php if ($customFieldValues): ?>
<div style="border-top:1px solid var(--border-soft); margin-top:12px; padding-top:12px">
<div style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:10px">Weitere Felder</div>
<?php foreach ($customFieldValues as $fv): ?>
<div style="margin-bottom:8px">
<div style="font-size:.7rem; color:var(--muted-lt)"><?= e($fv['label']) ?></div>
<div style="font-size:.82rem; font-weight:600"><?= $fv['field_type']==='checkbox' ? ($fv['value'] ? 'Ja' : 'Nein') : e($fv['value']) ?></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php $csatRow = null; try { $csatRow = csat_for_ticket($pdo, (int) $ticket['id']); } catch (Throwable $e) {} ?>
<?php if ($csatRow): ?>
<!-- Zufriedenheit des Kunden -->
<div class="card" data-ptab="details" style="padding:14px 20px">
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Kundenzufriedenheit</span>
<?php if ($csatRow['rating'] !== null): ?>
<div style="font-size:1.25rem;color:#f5b301;margin-top:4px"><?= e(csat_stars((int) $csatRow['rating'])) ?> <span style="font-size:.85rem;color:var(--muted)"><?= (int) $csatRow['rating'] ?> von 5</span></div>
<?php if (!empty($csatRow['comment'])): ?><div style="font-size:.85rem;margin-top:4px">„<?= e((string) $csatRow['comment']) ?>“</div><?php endif; ?>
<div class="muted" style="font-size:.76rem;margin-top:4px">Bewertet am <?= e(format_dt((string) $csatRow['answered_at'])) ?></div>
<?php else: ?>
<div class="muted" style="font-size:.84rem;margin-top:4px">Abfrage am <?= e(format_dt((string) $csatRow['sent_at'])) ?> gesendet, noch keine Antwort.</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Wiedervorlage und Beobachter -->
<div class="card" data-ptab="manage" style="padding:16px 20px">
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Wiedervorlage</span>
<?php if ($snoozedUntil): ?>
<p style="font-size:.84rem;margin:8px 0">💤 Bis <strong><?= e(date('d.m.Y H:i', (int) strtotime($snoozedUntil))) ?> Uhr</strong> ausgeblendet, dann kommt es zurück.</p>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="unsnooze"><button type="submit" class="btn secondary" style="width:100%;font-size:.82rem;justify-content:center">Jetzt zurückholen</button></form>
<?php else: ?>
<form method="post" style="margin-top:8px">
<?= csrf_field() ?><input type="hidden" name="action" value="snooze">
<select name="snooze_choice" style="font-size:.84rem;padding:7px 10px;margin-bottom:6px" onchange="document.getElementById('snooze-custom').style.display=this.value==='custom'?'':'none'">
<?php foreach (ticket_snooze_options() as $k => $l): ?><option value="<?= e($k) ?>"><?= e($l) ?></option><?php endforeach; ?>
</select>
<input type="datetime-local" name="snooze_custom" id="snooze-custom" style="display:none;font-size:.84rem;padding:7px 10px;margin-bottom:6px">
<button type="submit" class="btn secondary" style="width:100%;font-size:.82rem;justify-content:center">Auf später legen</button>
</form>
<p style="font-size:.76rem;color:var(--muted);margin:6px 0 0">Das Ticket verschwindet aus den Listen und kommt zum Zeitpunkt zurück, mit Benachrichtigung. Antwortet der Kunde früher, kommt es sofort zurück.</p>
<?php endif; ?>
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border-soft)">
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Beobachter</span>
<?php if ($followers): ?>
<div style="font-size:.83rem;margin:6px 0"><?= e(implode(', ', array_column($followers, 'name'))) ?></div>
<?php else: ?>
<p style="font-size:.79rem;color:var(--muted);margin:6px 0">Niemand. Beobachter bekommen Benachrichtigungen, ohne dass ihnen das Ticket zugewiesen ist.</p>
<?php endif; ?>
<form method="post"><?= csrf_field() ?><input type="hidden" name="action" value="<?= $isFollowing ? 'unfollow' : 'follow' ?>"><button type="submit" class="btn secondary" style="width:100%;font-size:.82rem;justify-content:center"><?= $isFollowing ? 'Nicht mehr beobachten' : '👁 Ticket beobachten' ?></button></form>
</div>
</div>
<?php if ($similar['articles'] || $similar['tickets']): ?>
<!-- Ähnliche Artikel und Tickets -->
<div class="card" data-ptab="more" style="padding:16px 20px">
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Dazu passend</span>
<?php if ($similar['articles']): ?>
<div style="font-size:.76rem;color:var(--muted);margin:8px 0 2px">Wissensdatenbank</div>
<?php foreach ($similar['articles'] as $sa): ?>
<div style="font-size:.84rem;padding:3px 0"><a href="<?= e($sa['url']) ?>" target="_blank" rel="noopener"><?= e($sa['title']) ?></a> <button type="button" class="link" style="background:none;border:0;color:var(--muted);cursor:pointer;font-size:.74rem" onclick="navigator.clipboard&&navigator.clipboard.writeText(location.origin+'<?= e($sa['url']) ?>');this.textContent='kopiert'">Link kopieren</button></div>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($similar['tickets']): ?>
<div style="font-size:.76rem;color:var(--muted);margin:8px 0 2px">Ähnliche gelöste Tickets</div>
<?php foreach ($similar['tickets'] as $st): ?>
<div style="font-size:.84rem;padding:3px 0"><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $st['number'])) ?>" target="_blank" rel="noopener">#<?= e($st['number']) ?></a> <?= e(mb_strimwidth((string) $st['subject'], 0, 48, '…')) ?></div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Kollaboratoren -->
<div class="card" data-ptab="manage" style="padding:16px 20px">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px">
<span style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted)">Kollaboratoren</span>
<button type="button" id="collab-toggle" onclick="var f=document.getElementById('collab-form');f.style.display=f.style.display==='none'?'':'none'" style="background:none;border:1px solid var(--border);border-radius:5px;padding:2px 8px;font-size:.73rem;cursor:pointer;color:var(--muted)">+ hinzufügen</button>
</div>
<?php if ($collaborators): ?>
<?php foreach ($collaborators as $cc): ?>
<div style="display:flex; justify-content:space-between; align-items:center; padding:5px 0; border-bottom:1px solid var(--border-soft); gap:8px">
<div style="min-width:0">
<div style="font-size:.83rem; font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($cc['name'] ?: $cc['email']) ?></div>
<?php if ($cc['name']): ?><div style="font-size:.73rem; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($cc['email']) ?></div><?php endif; ?>
</div>
<form method="post" style="display:inline;flex-shrink:0" onsubmit="return confirm('Kollaborator entfernen?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="remove_collaborator">
<input type="hidden" name="collaborator_id" value="<?= $cc['id'] ?>">
<button type="submit" style="background:none;border:none;color:var(--muted-lt);cursor:pointer;padding:2px 4px;font-size:.85rem" title="Entfernen">✕</button>
</form>
</div>
<?php endforeach; ?>
<?php else: ?>
<p style="font-size:.79rem; color:var(--muted); margin:0 0 6px">Erhalten eine Kopie jeder öffentlichen Antwort per E-Mail.</p>
<?php endif; ?>
<form method="post" id="collab-form" style="display:none; margin-top:10px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="add_collaborator">
<input type="text" name="collaborator_name" placeholder="Name (optional)" style="margin-bottom:5px; font-size:.84rem; padding:7px 10px">
<input type="email" name="collaborator_email" placeholder="E-Mail-Adresse" required style="margin-bottom:8px; font-size:.84rem; padding:7px 10px">
<button type="submit" class="btn secondary" style="width:100%; font-size:.82rem; justify-content:center">Hinzufügen</button>
</form>
</div>
<!-- Selten: Zusammenführen + Löschen -->
<details class="card" data-ptab="more" style="padding:0; overflow:hidden">
<summary style="padding:12px 16px; cursor:pointer; font-size:.79rem; font-weight:600; color:var(--muted); display:flex; align-items:center; justify-content:space-between; user-select:none; list-style:none">
<span>Weitere Aktionen</span><span style="font-size:.68rem; transition:transform .2s">▾</span>
</summary>
<div style="border-top:1px solid var(--border-soft); padding:14px 16px; display:flex; flex-direction:column; gap:12px">
<div>
<div style="font-size:.79rem; font-weight:600; color:var(--ink-soft); margin-bottom:6px">Tickets zusammenführen</div>
<p style="font-size:.75rem; color:var(--muted); margin:0 0 10px">Verlauf, Tags und Collaborators werden ins Ziel-Ticket verschoben. Dieses Ticket wird geschlossen.</p>
<!-- Live-Suche -->
<input type="text" id="merge-search" placeholder="Ticketnummer oder Betreff suchen…"
style="font-size:.82rem;padding:7px 10px;width:100%;box-sizing:border-box;margin-bottom:8px"
oninput="mergeSearch(this.value)">
<div id="merge-results" style="display:none;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-bottom:8px;max-height:200px;overflow-y:auto"></div>
<!-- Vorschau des gewählten Tickets -->
<div id="merge-preview" style="display:none;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:.8rem;margin-bottom:10px">
<div style="font-weight:600;margin-bottom:4px" id="merge-preview-title"></div>
<div style="color:var(--muted)" id="merge-preview-meta"></div>
</div>
<form method="post" id="merge-form" onsubmit="return confirmMerge()">
<?= csrf_field() ?>
<input type="hidden" name="action" value="merge_ticket">
<input type="hidden" name="target_ticket_number" id="merge-target-num" required>
<div style="margin-bottom:8px">
<label style="font-size:.76rem;color:var(--muted);display:block;margin-bottom:4px">Richtung:</label>
<div style="display:flex;gap:10px">
<label style="font-size:.8rem;display:flex;align-items:center;gap:5px;cursor:pointer;font-weight:400">
<input type="radio" name="merge_direction" value="into" checked style="width:auto">
Dieses Ticket <strong>→</strong> Ziel
</label>
<label style="font-size:.8rem;display:flex;align-items:center;gap:5px;cursor:pointer;font-weight:400">
<input type="radio" name="merge_direction" value="from" style="width:auto">
Ziel <strong>→</strong> Dieses Ticket
</label>
</div>
</div>
<button type="submit" id="merge-submit-btn" disabled
class="btn secondary" style="width:100%;font-size:.82rem;opacity:.5">
Zusammenführen
</button>
</form>
</div>
<div style="border-top:1px solid var(--border-soft); padding-top:12px">
<form method="post" onsubmit="return confirm('Ticket inkl. Verlauf unwiderruflich löschen?');">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_ticket">
<button type="submit" style="width:100%; background:none; border:1.5px solid var(--danger); color:var(--danger); border-radius:var(--radius); padding:8px 14px; font-size:.83rem; font-weight:600; cursor:pointer; font-family:var(--font-body)">🗑 Ticket löschen</button>
</form>
</div>
</div>
</details>
<!-- Zeiterfassung -->
<div class="card" data-ptab="manage" style="padding:16px 20px">
<div style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:10px">
&#9201; Zeiterfassung
<?php if ($totalMinutes > 0): ?>
<span style="float:right; font-size:.78rem; color:var(--ink); text-transform:none; letter-spacing:0">
Gesamt: <?= $totalMinutes >= 60 ? floor($totalMinutes/60).'h '.($totalMinutes%60).'min' : $totalMinutes.' Min.' ?>
</span>
<?php endif; ?>
</div>
<form method="post" style="display:flex; gap:6px; margin-bottom:10px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="log_time">
<input type="number" name="minutes" min="1" max="9999" placeholder="Min." required
style="width:70px; font-size:.82rem; padding:6px 8px">
<input type="text" name="time_note" placeholder="Notiz (optional)"
style="flex:1; font-size:.82rem; padding:6px 8px">
<button type="submit" class="btn secondary" style="font-size:.8rem; padding:6px 12px; white-space:nowrap">+ Erfassen</button>
</form>
<?php if ($timeEntries): ?>
<?php foreach ($timeEntries as $te): ?>
<div style="display:flex; justify-content:space-between; align-items:center; padding:4px 0; border-top:1px solid var(--border-soft); font-size:.79rem">
<div>
<span style="font-weight:600"><?= $te['minutes'] >= 60 ? floor($te['minutes']/60).'h '.($te['minutes']%60).'min' : $te['minutes'].' Min.' ?></span>
<span style="color:var(--muted)"> &middot; <?= e($te['staff_name']) ?></span>
<?php if ($te['note']): ?><span style="color:var(--muted-lt)"> &middot; <?= e($te['note']) ?></span><?php endif; ?>
</div>
<?php if ($te['staff_id'] == $staff['id'] || $staff['role'] === 'admin'): ?>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="action" value="delete_time_entry">
<input type="hidden" name="entry_id" value="<?= $te['id'] ?>">
<button type="submit" style="background:none;border:none;color:var(--muted-lt);cursor:pointer;font-size:.8rem;padding:0 2px">&#10005;</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php else: ?>
<p style="font-size:.78rem; color:var(--muted); margin:0">Noch keine Zeit erfasst.</p>
<?php endif; ?>
</div>
<!-- Kunden sperren -->
<?php
$requesterEmail = $ticket['requester_email'] ?? '';
$requesterDomain = $requesterEmail ? substr($requesterEmail, strpos($requesterEmail,'@')+1) : '';
$alreadyBanned = false;
if ($requesterEmail) {
$bs = $pdo->prepare('SELECT id FROM banlist WHERE value IN (?,?) LIMIT 1');
$bs->execute([$requesterEmail, $requesterDomain]);
$alreadyBanned = (bool)$bs->fetchColumn();
}
?>
<div data-ptab="more"><?= plugin_filter('ticket.view.sidebar', '', ['ticketId' => (int) $ticket['id'], 'ticket' => $ticket, 'staff' => $staff]) /* Plugins liefern fertiges HTML (vertrauenswürdiger Code) */ ?></div>
<?php if ($requesterEmail): ?>
<div class="card" data-ptab="details" style="margin-top:16px;padding:14px 16px">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:10px">Absender</div>
<div style="font-size:.83rem;margin-bottom:8px;word-break:break-all"><?= e($requesterEmail) ?></div>
<?php if ($alreadyBanned): ?>
<span style="background:#fee2e2;color:#dc2626;font-size:.75rem;padding:3px 10px;border-radius:20px;font-weight:600">⛔ Gesperrt</span>
<?php else: ?>
<details style="margin-top:6px">
<summary style="cursor:pointer;font-size:.78rem;color:var(--danger);font-weight:600">Absender sperren…</summary>
<form method="post" style="margin-top:10px" onsubmit="return confirm('Wirklich sperren?')">
<?= csrf_field() ?>
<input type="hidden" name="action" value="ban_user">
<label style="display:flex;align-items:center;gap:6px;font-size:.78rem;font-weight:400;margin-bottom:6px;cursor:pointer">
<input type="checkbox" name="ban_email" value="<?= e($requesterEmail) ?>" checked style="width:auto">
E-Mail: <?= e($requesterEmail) ?>
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:.78rem;font-weight:400;margin-bottom:8px;cursor:pointer">
<input type="checkbox" name="ban_domain" value="<?= e($requesterDomain) ?>" style="width:auto">
Domain: @<?= e($requesterDomain) ?>
</label>
<input type="text" name="ban_reason" placeholder="Grund (optional)" style="font-size:.78rem;margin-bottom:8px">
<button type="submit" class="btn secondary" style="font-size:.75rem;padding:5px 12px;border-color:var(--danger);color:var(--danger)">Sperren</button>
</form>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Kundenbewertung -->
<?php if (get_setting('ticket_ratings_enabled','1')==='1' && in_array($ticket['status'],['resolved','closed'])): ?>
<div class="card" data-ptab="details" style="margin-top:16px; padding:18px 20px">
<div style="font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:12px">Kundenbewertung</div>
<?php if ($ticketRating): ?>
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px">
<div style="font-size:1.4rem; line-height:1; letter-spacing:1px">
<?php for ($__i=1;$__i<=5;$__i++): ?><?= $__i<=$ticketRating['rating']?'⭐':'☆' ?><?php endfor; ?>
</div>
<span style="font-weight:700; font-size:.95rem; color:var(--ink)"><?= $ticketRating['rating'] ?>/5</span>
</div>
<?php if ($ticketRating['comment']): ?>
<p style="font-size:.83rem; color:var(--ink); margin:0 0 8px; font-style:italic; border-left:3px solid var(--primary); padding-left:10px">"<?= e($ticketRating['comment']) ?>"</p>
<?php endif; ?>
<div class="muted" style="font-size:.72rem"><?= format_dt($ticketRating['created_at']) ?></div>
<?php else: ?>
<p class="muted" style="font-size:.83rem; margin:0">Noch keine Bewertung abgegeben.</p>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script>
// Interne Kommentare: Toggle
function toggleComment(msgId) {
var wrap = document.getElementById('comment-wrap-' + msgId);
var btn = document.getElementById('comment-btn-' + msgId);
if (!wrap) return;
var visible = wrap.style.display !== 'none';
wrap.style.display = visible ? 'none' : 'flex';
if (!visible) {
var ta = wrap.querySelector('textarea');
if (ta) ta.focus();
}
}
</script>
<script>
// Ticket-Zusammenführen: Live-Suche
var mergeDebounce;
var mergeSelected = null;
var currentTicketNum = '<?= e($ticket['ticket_number']) ?>';
function mergeSearch(q) {
clearTimeout(mergeDebounce);
var results = document.getElementById('merge-results');
if (q.length < 2) { results.style.display = 'none'; return; }
mergeDebounce = setTimeout(function() {
fetch('/staff/ticket_merge_search.php?q=' + encodeURIComponent(q) + '&exclude=' + encodeURIComponent(currentTicketNum))
.then(function(r){ return r.json(); })
.then(function(data) {
if (!data.length) {
results.innerHTML = '<div style="padding:10px;font-size:.82rem;color:var(--muted)">Keine Tickets gefunden.</div>';
} else {
results.innerHTML = data.map(function(t) {
return '<div class="merge-result-item" onclick="selectMergeTicket(' + JSON.stringify(t) + ')"'
+ ' style="padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--border-soft);font-size:.82rem"'
+ ' onmouseover="this.style.background=\'var(--bg-soft)\'" onmouseout="this.style.background=\'\'"'
+ '>'
+ '<strong style="color:var(--primary)">#' + t.ticket_number + '</strong> '
+ '<span>' + t.subject + '</span> '
+ '<span style="color:var(--muted);font-size:.76rem">' + t.requester_name + ' · ' + t.status + '</span>'
+ '</div>';
}).join('');
}
results.style.display = 'block';
})
.catch(function(){
results.innerHTML = '<div style="padding:10px;font-size:.82rem;color:var(--danger)">Fehler beim Suchen.</div>';
results.style.display = 'block';
});
}, 300);
}
function selectMergeTicket(t) {
mergeSelected = t;
document.getElementById('merge-target-num').value = t.ticket_number;
document.getElementById('merge-results').style.display = 'none';
document.getElementById('merge-search').value = '#' + t.ticket_number + ' – ' + t.subject;
// Vorschau zeigen
document.getElementById('merge-preview-title').textContent = '#' + t.ticket_number + ': ' + t.subject;
document.getElementById('merge-preview-meta').textContent =
t.requester_name + ' · ' + t.department_name + ' · ' + t.status + ' · ' + t.priority_name;
document.getElementById('merge-preview').style.display = 'block';
// Submit aktivieren
var btn = document.getElementById('merge-submit-btn');
btn.disabled = false;
btn.style.opacity = '1';
}
function confirmMerge() {
if (!mergeSelected) return false;
var dir = document.querySelector('input[name=merge_direction]:checked').value;
var from = dir === 'from' ? '#' + mergeSelected.ticket_number : '#' + currentTicketNum;
var into = dir === 'from' ? '#' + currentTicketNum : '#' + mergeSelected.ticket_number;
return confirm('Ticket ' + from + ' in ' + into + ' zusammenführen?\n\nDieser Vorgang kann nicht rückgängig gemacht werden.');
}
// Klick außerhalb schließt Suchergebnisse
document.addEventListener('click', function(e) {
var r = document.getElementById('merge-results');
if (r && !r.contains(e.target) && e.target.id !== 'merge-search') r.style.display = 'none';
});
</script>
<script>
// Canned Responses mit Variablen-Ersetzung
function resolveCannedVars(text) {
var vars = window.cannedVars || {};
return text.replace(/\{\{(\w+)\}\}/g, function(match, key) {
return vars[key] !== undefined ? vars[key] : match;
});
}
function applyCannedResponse(select) {
if (!select.value) {
document.getElementById('canned-preview-btn').style.display = 'none';
document.getElementById('canned-preview').style.display = 'none';
return;
}
var body = select.options[select.selectedIndex].dataset.body || '';
var resolved = resolveCannedVars(body);
// Sofort einfügen
insertIntoRichField('message-field', resolved);
// Vorschau aktualisieren
var preview = document.getElementById('canned-preview');
preview.innerHTML = resolved;
document.getElementById('canned-preview-btn').style.display = 'inline-block';
select.value = '';
}
function toggleCannedPreview() {
var p = document.getElementById('canned-preview');
var btn = document.getElementById('canned-preview-btn');
var visible = p.style.display !== 'none';
p.style.display = visible ? 'none' : 'block';
btn.textContent = visible ? 'Vorschau' : 'Vorschau ausblenden';
}
</script>
<script>
// E-Mail Quelltext Toggle
function toggleRaw(id) {
var raw = document.getElementById('raw-' + id);
var rendered = document.getElementById('rendered-' + id);
var btn = document.getElementById('raw-btn-' + id);
if (!raw) return;
var showing = raw.style.display !== 'none';
raw.style.display = showing ? 'none' : 'block';
rendered.style.display = showing ? 'block' : 'none';
btn.textContent = showing ? 'Quelltext' : 'Gerendert';
btn.style.background = showing ? 'var(--bg-soft)' : 'var(--primary)';
btn.style.color = showing ? 'var(--muted)' : '#fff';
btn.style.borderColor = showing ? 'var(--border)' : 'var(--primary)';
}
</script>
<script>
// @Mention Autocomplete
(function() {
var names = <?= json_encode($mentionNames) ?>;
var field = document.getElementById('message-field');
if (!field) return;
var box = document.createElement('ul');
box.id = 'mention-box';
box.style.cssText = 'position:fixed;background:#fff;border:1px solid #d1d5db;border-radius:6px;list-style:none;margin:0;padding:4px 0;z-index:9999;min-width:160px;box-shadow:0 4px 12px rgba(0,0,0,.14);display:none;max-height:180px;overflow-y:auto';
document.body.appendChild(box);
var atPos = -1;
field.addEventListener('input', function() {
var val = field.value, pos = field.selectionStart;
var before = val.substring(0, pos);
var m = before.match(/@(\w*)$/);
if (!m) { box.style.display='none'; return; }
var q = m[1].toLowerCase();
var hits = names.filter(function(n){ return n.toLowerCase().indexOf(q) === 0 || (q.length > 1 && n.toLowerCase().indexOf(q) > -1); }).slice(0,8);
if (!hits.length) { box.style.display='none'; return; }
// Sicheres DOM-Building statt innerHTML (verhindert XSS durch Staff-Namen mit HTML)
box.innerHTML = '';
hits.forEach(function(n) {
var li = document.createElement('li');
li.dataset.name = n;
li.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:.88rem;white-space:nowrap';
li.textContent = n; // textContent escaped automatisch
li.addEventListener('mousedown', function(e) { e.preventDefault(); insertMention(li.dataset.name); });
box.appendChild(li);
});
var r = field.getBoundingClientRect();
box.style.left = r.left + 'px';
box.style.top = (r.bottom + 4) + 'px';
box.style.display = 'block';
atPos = before.lastIndexOf('@');
});
document.addEventListener('click', function(){ box.style.display='none'; });
function insertMention(name) {
var val = field.value, pos = field.selectionStart;
var before = val.substring(0, atPos);
var after = val.substring(pos);
field.value = before + '@' + name + ' ' + after;
field.selectionStart = field.selectionEnd = atPos + name.length + 2;
field.focus();
box.style.display = 'none';
}
})();
</script>
<script>
(function() {
var lastCount = <?= count($thread) ?>;
var ticketNumber = <?= json_encode($ticket['ticket_number']) ?>;
function checkUpdates() {
fetch('/staff/ticket_poll_staff.php?n=' + encodeURIComponent(ticketNumber) + '&count=' + lastCount)
.then(function(r){ return r.json(); })
.then(function(data){
if (data.new_count > lastCount) {
window.location.reload();
}
})
.catch(function(){});
}
setInterval(checkUpdates, 15000);
})();
</script>
<style>
@media print {
.app-shell > nav, ._sidebar, .staff-sidebar, nav.sidebar,
[class*="sidebar"], .topbar .btn-row, .card:last-of-type form,
details, button[type="submit"], .btn, .btn-row,
/* Interne Kommentare */
.thread-comments { font-size:.83rem; }
.comment-input-wrap { display:none; }
.thread-comments > div:last-child { border-bottom: none; }
/* E-Mail HTML Body */
.email-html-body { max-width:100%; overflow-x:auto; }
.email-html-body table { border-collapse:collapse; max-width:100%; }
.email-html-body td, .email-html-body th { padding:4px 8px; }
.email-html-body img { max-width:100%; height:auto; }
.email-html-body blockquote { border-left:3px solid var(--border); margin:8px 0; padding:6px 12px; color:var(--muted); }
.email-html-body a { color:var(--primary); }
.email-quote-wrap { margin:10px 0; }
.email-quote-toggle { background:none; border:1px solid var(--border); border-radius:4px; padding:2px 8px; font-size:.75rem; color:var(--muted); cursor:pointer; }
.email-quote-toggle:hover { background:var(--border-soft); }
select, input[type="file"], label[for="attachments"],
label[for="canned_picker"], #canned_picker { display: none !important; }
.thread-msg { break-inside: avoid; border: 1px solid #ccc !important; margin-bottom: 12px !important; box-shadow: none !important; }
body, .app-shell { background: white !important; }
.main-content { margin: 0 !important; padding: 0 !important; }
}
</style>
<script>
(function() {
var ticketId = <?= (int)$ticket['id'] ?>;
var banner = document.getElementById('presence-banner');
var text = document.getElementById('presence-text');
function ping() {
fetch('/staff/ticket_presence.php?ticket_id=' + ticketId + '&action=ping')
.then(function(r) { return r.json(); })
.then(function(users) {
if (users.length > 0) {
var names = users.map(function(u) { return u.name; });
var label = names.length === 1
? names[0] + ' schaut gerade auf dieses Ticket'
: names.slice(0, -1).join(', ') + ' und ' + names[names.length-1] + ' schauen gerade auf dieses Ticket';
text.textContent = label;
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
})
.catch(function() {});
}
// Sofort und dann alle 15 Sekunden
ping();
setInterval(ping, 15000);
window.addEventListener('beforeunload', function() {
navigator.sendBeacon('/staff/ticket_presence.php?ticket_id=' + ticketId + '&action=leave');
});
})();
</script>
<style>
.tv-tabs { grid-column:1 / -1; display:flex; gap:2px; margin:0; border-bottom:1px solid var(--border,#e2e8f0); overflow-x:auto; overflow-y:hidden; }
.tv-tab { background:none; border:0; border-bottom:3px solid transparent; padding:9px 14px; margin-bottom:-1px; cursor:pointer; font:inherit; font-weight:600; color:var(--muted,#64748b); border-radius:8px 8px 0 0; white-space:nowrap; }
.tv-tab:hover { color:var(--text,#0f172a); background:var(--surface,#fff); }
.tv-tab.active { color:var(--primary); border-bottom-color:var(--primary); background:var(--surface,#fff); }
[data-ptab][hidden] { display:none !important; }
</style>
<script>
// Reiter unter dem Verlauf: zeigt nur die Karten der gewählten Gruppe. Die Wahl bleibt beim Neuladen (z. B. nach dem Speichern) erhalten.
(function () {
var bar = document.getElementById('tv-tabs');
if (!bar) return;
var tabs = bar.querySelectorAll('.tv-tab');
function show(name) {
var found = false;
tabs.forEach(function (b) { var on = b.dataset.tab === name; if (on) found = true; b.classList.toggle('active', on); b.setAttribute('aria-selected', on ? 'true' : 'false'); });
if (!found) return show('details');
document.querySelectorAll('[data-ptab]').forEach(function (el) { el.hidden = el.dataset.ptab !== name; });
try { sessionStorage.setItem('tv_tab', name); } catch (e) {}
}
tabs.forEach(function (b) { b.addEventListener('click', function () { show(b.dataset.tab); }); });
var saved = 'details';
try { saved = sessionStorage.getItem('tv_tab') || 'details'; } catch (e) {}
show(saved);
})();
</script>
<?php require __DIR__ . '/_footer.php'; ?>