1795 lines
100 KiB
PHP
1795 lines
100 KiB
PHP
<?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();
|
||
|
||
$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) {
|
||
$affected = $pdo->prepare(
|
||
"UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE requester_email=? AND status NOT IN ('closed')"
|
||
);
|
||
$affected->execute([db_now(), db_now(), $banEmail]);
|
||
}
|
||
if ($banDomain) {
|
||
$affected2 = $pdo->prepare(
|
||
"UPDATE tickets SET status='closed', closed_at=?, updated_at=? WHERE " . sql_email_domain("requester_email") . "=? AND status NOT IN ('closed')"
|
||
);
|
||
$affected2->execute([db_now(), db_now(), $banDomain]);
|
||
}
|
||
flash('success', 'Gesperrt und alle zugehörigen Tickets geschlossen.');
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']));
|
||
}
|
||
|
||
verify_csrf();
|
||
$action = $_POST['action'] ?? '';
|
||
$now = db_now();
|
||
|
||
// Rechte gelten pro Abteilung des Tickets (Rolle des Mitarbeiters in dieser Abteilung).
|
||
$requirePerm = function (string $perm) use ($staff, $ticket, $number): void {
|
||
if (!has_permission($staff, $perm, (int) $ticket['department_id'])) {
|
||
flash('error', 'Für diese Aktion fehlt die erforderliche Berechtigung.');
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
};
|
||
if ($action === 'reply') $requirePerm('reply_ticket');
|
||
if ($action === 'note') $requirePerm('post_note');
|
||
if ($action === 'delete_ticket') $requirePerm('delete_ticket');
|
||
if ($action === 'mark_spam' || $action === 'restore_spam') $requirePerm('close_ticket');
|
||
|
||
if ($action === 'add_comment') {
|
||
$threadId = (int)($_POST['thread_id'] ?? 0);
|
||
$message = trim($_POST['comment_message'] ?? '');
|
||
if ($threadId && $message !== '') {
|
||
// Verify thread belongs to this ticket
|
||
$chk = $pdo->prepare('SELECT id FROM ticket_threads WHERE id = ? AND ticket_id = ?');
|
||
$chk->execute([$threadId, $ticket['id']]);
|
||
if ($chk->fetchColumn()) {
|
||
$pdo->prepare(
|
||
'INSERT INTO ticket_thread_comments (thread_id, staff_id, message, created_at) VALUES (?,?,?,?)'
|
||
)->execute([$threadId, $staff['id'], htmlspecialchars($message, ENT_QUOTES, 'UTF-8'), $now]);
|
||
}
|
||
}
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number) . '#msg-' . $threadId);
|
||
}
|
||
|
||
if ($action === 'delete_comment') {
|
||
$commentId = (int)($_POST['comment_id'] ?? 0);
|
||
// Only allow deletion by the comment author or admin
|
||
$chk = $pdo->prepare(
|
||
'SELECT tc.id FROM ticket_thread_comments tc
|
||
JOIN ticket_threads tt ON tt.id = tc.thread_id
|
||
WHERE tc.id = ? AND tt.ticket_id = ? AND (tc.staff_id = ? OR ? = \'admin\')'
|
||
);
|
||
$chk->execute([$commentId, $ticket['id'], $staff['id'], $staff['role']]);
|
||
if ($chk->fetchColumn()) {
|
||
$pdo->prepare('DELETE FROM ticket_thread_comments WHERE id = ?')->execute([$commentId]);
|
||
}
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
if ($action === 'reply' || $action === 'note') {
|
||
$raw = trim($_POST['message'] ?? '');
|
||
// Variablen serverseitig auflösen (Fallback falls JS deaktiviert)
|
||
$raw = resolve_canned_vars($raw, $ticket, $staff);
|
||
$message = sanitize_staff_html($raw);
|
||
if (trim(strip_tags($message)) === '') {
|
||
$errors[] = 'Bitte geben Sie eine Nachricht ein.';
|
||
} else {
|
||
$isNote = $action === 'note' ? 1 : 0;
|
||
$stmt = $pdo->prepare(
|
||
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
|
||
VALUES (?,?,?,?,?,?)'
|
||
);
|
||
$stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote, $now]);
|
||
$threadId = (int) $pdo->lastInsertId();
|
||
|
||
if (!empty($_FILES['attachments']['name'][0])) {
|
||
$upResults = handle_ticket_uploads_multi($_FILES['attachments']);
|
||
foreach ($upResults as $up) {
|
||
if (isset($up['error'])) {
|
||
$errors[] = $up['error'];
|
||
} else {
|
||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')
|
||
->execute([$threadId, $up['original_name'], $up['stored_name'], $up['filesize'], $now]);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!$isNote) {
|
||
$newStatus = $_POST['set_status'] ?: 'answered';
|
||
$updateFirstResponse = $ticket['first_responded_at'] === null;
|
||
|
||
// Auto-Zuweisung an antwortenden Mitarbeiter, falls Ticket noch niemandem zugewiesen ist
|
||
$autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === '');
|
||
$stmt = $pdo->prepare(
|
||
'UPDATE tickets SET status = ?, updated_at = ?' .
|
||
($updateFirstResponse ? ', first_responded_at = ?' : '') .
|
||
($autoAssign ? ', assigned_staff_id = ?' : '') .
|
||
' WHERE id = ?'
|
||
);
|
||
$args = [];
|
||
$args[] = $newStatus;
|
||
$args[] = $now;
|
||
if ($updateFirstResponse) $args[] = $now;
|
||
if ($autoAssign) $args[] = $staff['id'];
|
||
$args[] = $ticket['id'];
|
||
$stmt->execute($args);
|
||
if ($autoAssign) {
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (erste Antwort)');
|
||
}
|
||
|
||
// Signatur laut Auswahl im Antwortformular (eigene, Abteilung oder keine); ohne Angabe wie bisher
|
||
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], isset($_POST['signature_choice']) ? (string) $_POST['signature_choice'] : null);
|
||
$ccEmails = array_column($collaborators, 'email');
|
||
notify_customer_new_reply($ticket, $message, $signature, $ccEmails);
|
||
save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Öffentliche Antwort gesendet');
|
||
// Browser-Push bei neuer Kundenantwort
|
||
if (file_exists(__DIR__ . '/../includes/push.php')) {
|
||
require_once __DIR__ . '/../includes/push.php';
|
||
push_new_reply($ticket, $staff['name']);
|
||
}
|
||
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 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 WHERE id = ?')->execute([$ticket['id']]);
|
||
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Sperre manuell aufgehoben');
|
||
flash('success', 'Sperre aufgehoben.');
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
// Admin-Override: hebt die Sperre eines ANDEREN Mitarbeiters sofort auf, statt bis zum
|
||
// automatischen Timeout zu warten (siehe flash_locked_ticket() / TICKET_LOCK_TIMEOUT_MINUTES
|
||
// in includes/functions.php). Läuft bewusst über POST statt über die GET-Sperrprüfung
|
||
// oben, da genau die hier umgangen werden soll – daher die eigene Rechteprüfung hier.
|
||
if ($action === 'admin_force_unlock') {
|
||
if ($staff['role'] !== 'admin') {
|
||
flash('error', 'Nur Admins können fremde Sperren aufheben.');
|
||
redirect('/staff/tickets.php');
|
||
}
|
||
$unlockedFromName = $_POST['locked_by_name'] ?? 'einem anderen Mitarbeiter';
|
||
$pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], "Sperre von \"{$unlockedFromName}\" durch Admin aufgehoben");
|
||
flash('success', "Sperre aufgehoben – Sie können das Ticket jetzt öffnen.");
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
if ($action === 'merge_ticket') {
|
||
$targetNumber = trim($_POST['target_ticket_number'] ?? '');
|
||
$direction = $_POST['merge_direction'] ?? 'into'; // 'into' = dieses → Ziel
|
||
$stmt = $pdo->prepare(
|
||
'SELECT t.*, p.name AS priority_name, d.name AS department_name, s.name AS staff_name
|
||
FROM tickets t
|
||
JOIN priorities p ON p.id = t.priority_id
|
||
JOIN departments d ON d.id = t.department_id
|
||
LEFT JOIN staff s ON s.id = t.assigned_staff_id
|
||
WHERE t.ticket_number = ?'
|
||
);
|
||
$stmt->execute([$targetNumber]);
|
||
$target = $stmt->fetch();
|
||
|
||
if (!$target) {
|
||
flash('error', 'Ziel-Ticket #' . e($targetNumber) . ' wurde nicht gefunden.');
|
||
} elseif ($target['id'] === $ticket['id']) {
|
||
flash('error', 'Ein Ticket kann nicht mit sich selbst zusammengeführt werden.');
|
||
} elseif (!empty($target['merged_into'])) {
|
||
flash('error', 'Das Ziel-Ticket wurde bereits in ein anderes Ticket zusammengeführt.');
|
||
} elseif ($myDeptIds !== null && !in_array((int) $target['department_id'], $myDeptIds, true)) {
|
||
flash('error', 'Sie haben keinen Zugriff auf die Abteilung des Ziel-Tickets.');
|
||
} else {
|
||
// Bestimme Quelle und Ziel je nach Richtung
|
||
if ($direction === 'from') {
|
||
// Ziel-Ticket wird in dieses zusammengeführt
|
||
[$src, $dst] = [$target, $ticket];
|
||
} else {
|
||
// Dieses Ticket wird ins Ziel zusammengeführt (Standard)
|
||
[$src, $dst] = [$ticket, $target];
|
||
}
|
||
|
||
$pdo->beginTransaction();
|
||
try {
|
||
// Threads verschieben
|
||
$pdo->prepare('UPDATE ticket_threads SET ticket_id = ? WHERE ticket_id = ?')
|
||
->execute([$dst['id'], $src['id']]);
|
||
|
||
// Collaborators übernehmen (cross-DB)
|
||
$isSqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
||
$insertIgnore = $isSqlite ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
|
||
try {
|
||
$pdo->prepare(
|
||
"$insertIgnore INTO ticket_collaborators (ticket_id, user_id, created_at)
|
||
SELECT ?, user_id, created_at FROM ticket_collaborators WHERE ticket_id = ?"
|
||
)->execute([$dst['id'], $src['id']]);
|
||
} catch (Throwable $e) { /* Tabelle leer oder keine Collaborators */ }
|
||
|
||
// Tags übernehmen
|
||
try {
|
||
$pdo->prepare(
|
||
"$insertIgnore INTO ticket_tags (ticket_id, tag_id)
|
||
SELECT ?, tag_id FROM ticket_tags WHERE ticket_id = ?"
|
||
)->execute([$dst['id'], $src['id']]);
|
||
} catch (Throwable $e) { /* ignorieren */ }
|
||
|
||
// System-Notiz im Ziel-Ticket
|
||
$note = "Ticket #{$src['ticket_number']} wurde hierher zusammengeführt"
|
||
. " (Betreff: " . htmlspecialchars($src['subject'], ENT_QUOTES) . ")";
|
||
$pdo->prepare(
|
||
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
|
||
VALUES (?,?,?,?,1,?)'
|
||
)->execute([$dst['id'], 'staff', $staff['id'], $note, $now]);
|
||
|
||
// Quell-Ticket schließen + merged_into setzen
|
||
$pdo->prepare('UPDATE tickets SET status = ?, merged_into = ?, updated_at = ? WHERE id = ?')
|
||
->execute(['closed', $dst['id'], $now, $src['id']]);
|
||
|
||
// Ziel-Ticket aktualisieren (updated_at)
|
||
$pdo->prepare('UPDATE tickets SET updated_at = ? WHERE id = ?')
|
||
->execute([$now, $dst['id']]);
|
||
|
||
$pdo->commit();
|
||
log_activity($pdo, $dst['id'], $staff['id'], "Ticket #{$src['ticket_number']} hierher zusammengeführt");
|
||
log_activity($pdo, $src['id'], $staff['id'], "Zusammengeführt mit Ticket #{$dst['ticket_number']}");
|
||
audit_log($staff['id'], "Merge: #{$src['ticket_number']} → #{$dst['ticket_number']}");
|
||
flash('success', "Ticket #{$src['ticket_number']} wurde erfolgreich mit #{$dst['ticket_number']} zusammengeführt.");
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($dst['ticket_number']));
|
||
} catch (Throwable $e) {
|
||
$pdo->rollBack();
|
||
flash('error', 'Fehler beim Zusammenführen: ' . $e->getMessage());
|
||
}
|
||
}
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
if ($action === 'update_props') {
|
||
$deptId = (int) $_POST['department_id'];
|
||
if ($myDeptIds !== null && !in_array($deptId, $myDeptIds, true) && $deptId !== (int) $ticket['department_id']) {
|
||
flash('error', 'Sie haben keinen Zugriff auf diese Abteilung.');
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
$prioId = (int) $_POST['priority_id'];
|
||
$status = $_POST['status'];
|
||
$assignedTo = $_POST['assigned_staff_id'] !== '' ? (int) $_POST['assigned_staff_id'] : null;
|
||
$teamId = ($_POST['team_id'] ?? '') !== '' ? (int) $_POST['team_id'] : null;
|
||
|
||
if ($deptId !== (int) $ticket['department_id']) $requirePerm('transfer_ticket');
|
||
if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id']) || $teamId !== ($ticket['team_id'] === null ? null : (int) $ticket['team_id'])) $requirePerm('assign_ticket');
|
||
if ($status !== $ticket['status'] && in_array($status, ['resolved', 'closed'], true)) $requirePerm('close_ticket');
|
||
|
||
$prio = null;
|
||
foreach ($priorities as $p) { if ($p['id'] == $prioId) $prio = $p; }
|
||
|
||
$sla = $prio ? calc_sla_dates($prio, $ticket['created_at'], $deptId, !empty($ticket['help_topic_id']) ? (int) $ticket['help_topic_id'] : null) : null;
|
||
|
||
$resolvedAt = $ticket['resolved_at'];
|
||
$closedAt = $ticket['closed_at'];
|
||
if ($status === 'resolved' && $ticket['status'] !== 'resolved') $resolvedAt = $now;
|
||
if ($status === 'closed' && $ticket['status'] !== 'closed') $closedAt = $now;
|
||
|
||
$stmt = $pdo->prepare(
|
||
'UPDATE tickets SET department_id=?, priority_id=?, status=?, assigned_staff_id=?, team_id=?,
|
||
first_response_due_at=?, resolution_due_at=?, resolved_at=?, closed_at=?, updated_at=? WHERE id=?'
|
||
);
|
||
$stmt->execute([
|
||
$deptId, $prioId, $status, $assignedTo, $teamId,
|
||
$sla['first_response_due_at'] ?? $ticket['first_response_due_at'],
|
||
$sla['resolution_due_at'] ?? $ticket['resolution_due_at'],
|
||
$resolvedAt, $closedAt, $now, $ticket['id'],
|
||
]);
|
||
|
||
if ($assignedTo !== (int) ($ticket['assigned_staff_id'] ?? 0) && $assignedTo !== null) {
|
||
$agentName = '';
|
||
foreach ($agents as $a) { if ($a['id'] == $assignedTo) $agentName = $a['name']; }
|
||
log_activity($pdo, $ticket['id'], $staff['id'], "Mitarbeiter zugewiesen: {$agentName}");
|
||
fcm_notify_assignment($ticket, (int) $assignedTo, (int) $staff['id']); // Push auf die Android-App des neuen Bearbeiters
|
||
} elseif ($assignedTo === null && !empty($ticket['assigned_staff_id'])) {
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Zuweisung entfernt');
|
||
}
|
||
if ($teamId !== null && $teamId !== (int) ($ticket['team_id'] ?? 0)) {
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Team zugewiesen');
|
||
notify_team_assignment($pdo, $ticket, $teamId, (int) $staff['id']);
|
||
}
|
||
if ($status !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]);
|
||
if ($assignedTo !== ($ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id'])) webhook_dispatch($pdo, 'ticket.assigned', (int) $ticket['id']);
|
||
if ($status !== $ticket['status']) {
|
||
[$newLabel] = status_label($status);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], "Status geändert: {$newLabel}");
|
||
}
|
||
if ($prioId != $ticket['priority_id']) {
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Priorität geändert');
|
||
}
|
||
if ($deptId != $ticket['department_id']) {
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Abteilung geändert');
|
||
}
|
||
|
||
flash('success', 'Ticket aktualisiert.');
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
$stmt = $pdo->prepare(
|
||
'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
|
||
s.name AS staff_name
|
||
FROM tickets t
|
||
JOIN priorities p ON p.id=t.priority_id
|
||
JOIN departments d ON d.id=t.department_id
|
||
LEFT JOIN staff s ON s.id = t.assigned_staff_id
|
||
WHERE t.ticket_number = ?'
|
||
);
|
||
$stmt->execute([$number]);
|
||
$ticket = $stmt->fetch();
|
||
}
|
||
|
||
if ($action === 'log_time') {
|
||
$minutes = max(1, (int) ($_POST['minutes'] ?? 0));
|
||
$note = trim($_POST['time_note'] ?? '');
|
||
$pdo->prepare('INSERT INTO ticket_time_entries (ticket_id,staff_id,minutes,note,logged_at) VALUES (?,?,?,?,?)')
|
||
->execute([$ticket['id'], $staff['id'], $minutes, $note ?: null, db_now()]);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], "Zeit erfasst: {$minutes} Min." . ($note ? " ({$note})" : ''));
|
||
flash('success', 'Zeit erfasst.');
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
if ($action === 'delete_time_entry') {
|
||
$pdo->prepare('DELETE FROM ticket_time_entries WHERE id=? AND staff_id=?')
|
||
->execute([(int)$_POST['entry_id'], $staff['id']]);
|
||
redirect('/staff/ticket_view.php?n=' . urlencode($number));
|
||
}
|
||
|
||
// Thread (inkl. interner Notizen) laden
|
||
$stmt = $pdo->prepare(
|
||
"SELECT tt.*, s.name AS staff_name, u.name AS user_name
|
||
FROM ticket_threads tt
|
||
LEFT JOIN staff s ON s.id = tt.staff_id
|
||
LEFT JOIN users u ON u.id = tt.user_id
|
||
WHERE tt.ticket_id = ?
|
||
ORDER BY tt.created_at ASC"
|
||
);
|
||
$stmt->execute([$ticket['id']]);
|
||
$thread = $stmt->fetchAll();
|
||
$attStmt = $pdo->prepare('SELECT * FROM ticket_attachments WHERE thread_id = ?');
|
||
$commentStmt = $pdo->prepare(
|
||
'SELECT tc.*, s.name AS staff_name, s.avatar_path
|
||
FROM ticket_thread_comments tc
|
||
JOIN staff s ON s.id = tc.staff_id
|
||
WHERE tc.thread_id = ?
|
||
ORDER BY tc.created_at ASC'
|
||
);
|
||
|
||
$logStmt = $pdo->prepare(
|
||
"SELECT al.*, s.name AS staff_name FROM activity_log al LEFT JOIN staff s ON s.id=al.staff_id
|
||
WHERE al.ticket_id=? ORDER BY al.created_at DESC LIMIT 20"
|
||
);
|
||
$logStmt->execute([$ticket['id']]);
|
||
$activity = $logStmt->fetchAll();
|
||
|
||
// Zeiterfassung laden
|
||
$timeEntries = $pdo->prepare(
|
||
"SELECT te.*, s.name AS staff_name FROM ticket_time_entries te
|
||
JOIN staff s ON s.id=te.staff_id WHERE te.ticket_id=? ORDER BY te.logged_at DESC"
|
||
);
|
||
$timeEntries->execute([$ticket['id']]);
|
||
$timeEntries = $timeEntries->fetchAll();
|
||
$totalMinutes = array_sum(array_column($timeEntries, 'minutes'));
|
||
|
||
$mentionNames = $pdo->query('SELECT name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll(PDO::FETCH_COLUMN);
|
||
|
||
$pageTitle = "Ticket #{$ticket['ticket_number']}";
|
||
$activePage = 'tickets';
|
||
require __DIR__ . '/_header.php';
|
||
[$statusLabel, $statusColor] = status_label($ticket['status']);
|
||
$isOverdue = $ticket['resolution_due_at'] && strtotime($ticket['resolution_due_at']) < time() && !in_array($ticket['status'], ['resolved','closed']);
|
||
// Bewertung laden
|
||
$ticketRating = null;
|
||
if (in_array($ticket['status'], ['resolved','closed'])) {
|
||
$rStmt = $pdo->prepare('SELECT * FROM ticket_ratings WHERE ticket_id=?');
|
||
$rStmt->execute([$ticket['id']]);
|
||
$ticketRating = $rStmt->fetch();
|
||
}
|
||
?>
|
||
|
||
<!-- 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']) ?> <<?= e($ticket['requester_email']) ?>></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>„<?= e($adminLockedBy) ?>“</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:8px;padding:8px 16px;border-bottom:1px solid var(--border-soft)">
|
||
<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:1px">
|
||
<?= mb_strtoupper(mb_substr($co['staff_name'], 0, 1)) ?>
|
||
</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:3px">
|
||
<span style="font-size:.8rem;font-weight:600;color:var(--ink)"><?= e($co['staff_name']) ?></span>
|
||
<span style="font-size:.72rem;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:.83rem;color:var(--ink-soft);line-height:1.5"><?= 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">
|
||
<?= 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
|
||
style="flex:1;min-height:60px;resize:vertical;font-size:.83rem;padding:6px 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">
|
||
<option value="answered">Beantwortet</option>
|
||
<option value="pending">Wartet auf Kunde</option>
|
||
<option value="resolved">Gelöst</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<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') ?>: <?= e($log['action']) ?></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">
|
||
|
||
<!-- Ticket-Eigenschaften als horizontale Leiste -->
|
||
<div class="card" 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" 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>
|
||
<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>
|
||
</div>
|
||
|
||
<!-- SLA + Individuelle Felder -->
|
||
<div class="card" 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>
|
||
<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 = $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' : 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 = $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' : time_until($ticket['resolution_due_at']) ?></div>
|
||
</div>
|
||
</div>
|
||
<?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" 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" 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" 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" 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" 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" style="padding:16px 20px">
|
||
<div style="font-size:.71rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:10px">
|
||
⏱ 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)"> · <?= e($te['staff_name']) ?></span>
|
||
<?php if ($te['note']): ?><span style="color:var(--muted-lt)"> · <?= 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">✕</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();
|
||
}
|
||
?>
|
||
<?= plugin_filter('ticket.view.sidebar', '', ['ticketId' => (int) $ticket['id'], 'ticket' => $ticket, 'staff' => $staff]) /* Plugins liefern fertiges HTML (vertrauenswürdiger Code) */ ?>
|
||
<?php if ($requesterEmail): ?>
|
||
<div class="card" 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" 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>
|
||
|
||
<?php require __DIR__ . '/_footer.php'; ?>
|