617 lines
31 KiB
PHP
617 lines
31 KiB
PHP
<?php
|
||
/**
|
||
* staff/live_chat.php
|
||
*
|
||
* Eigenständiges Postfach für Live-Chats von Kunden (Tickets mit source='chat') –
|
||
* bewusst getrennt von staff/chat.php (interner Mitarbeiter-Chat, staff_chat_messages).
|
||
* Beide nutzen unterschiedliche Datenmodelle und werden nie vermischt: hier geht es
|
||
* ausschließlich um Konversationen mit Kunden über das Live-Chat-Widget im Kundenportal.
|
||
*
|
||
* Ein Live-Chat ist technisch ein ganz normales Ticket (siehe live_chat.php im Root-
|
||
* Verzeichnis) – hier gibt es nur eine auf schnelles Chatten zugeschnittene Oberfläche
|
||
* obendrauf. Für alles Erweiterte (Anhänge, Textbausteine, Zusammenführen, …) bleibt
|
||
* die normale Ticketansicht über "Im Ticket öffnen" weiter erreichbar.
|
||
*/
|
||
|
||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||
$staff = require_staff_login();
|
||
$pdo = get_db();
|
||
$myDeptIds = staff_department_ids($staff);
|
||
|
||
function lc_json($data): never
|
||
{
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
// Aktuellen CSRF-Token immer mitschicken: verify_csrf() rotiert ihn nach jeder
|
||
// erfolgreichen Anfrage, das JS aktualisiert seine lokale Kopie daraus. Ohne das
|
||
// schlägt jede zweite Nachricht/Chat-Ende mit einer (hier nicht JSON-lesbaren)
|
||
// "Ungültige Anfrage"-Antwort fehl, sobald der beim Laden eingebettete Token
|
||
// durch die vorherige Aktion bereits verbraucht/rotiert wurde.
|
||
if (is_array($data) && !isset($data['csrfToken'])) {
|
||
$data['csrfToken'] = csrf_token();
|
||
}
|
||
echo json_encode($data);
|
||
exit;
|
||
}
|
||
|
||
/** Wie lc_json(), schickt die Antwort aber sofort raus und beendet das Skript NICHT –
|
||
* der Aufrufer kann danach noch etwas Langsames erledigen (hier: die Chatverlauf-Mail
|
||
* per SMTP verschicken), ohne dass der Mitarbeiter auf den Klick auf "Chat beenden"
|
||
* warten muss. Gleiche Technik wie chat_flush_response() im Root-live_chat.php. */
|
||
function lc_flush_response(array $data): void
|
||
{
|
||
if (!isset($data['csrfToken'])) {
|
||
$data['csrfToken'] = csrf_token();
|
||
}
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
if (ob_get_level() === 0) ob_start();
|
||
echo json_encode($data);
|
||
if (!headers_sent()) {
|
||
header('Content-Length: ' . ob_get_length());
|
||
header('Connection: close');
|
||
}
|
||
if (function_exists('fastcgi_finish_request')) {
|
||
@fastcgi_finish_request();
|
||
} else {
|
||
while (ob_get_level() > 0) { @ob_end_flush(); }
|
||
@flush();
|
||
}
|
||
if (session_status() === PHP_SESSION_ACTIVE) session_write_close();
|
||
}
|
||
|
||
/** JSON-taugliche CSRF-Prüfung für die AJAX-Endpunkte dieser Seite – verify_csrf()
|
||
* bricht bei Fehlschlag mit einer reinen Textmeldung ab, die fetch().json() nicht
|
||
* parsen kann. Rotiert den Token nach Verbrauch genauso wie verify_csrf(). */
|
||
function lc_verify_csrf(): void
|
||
{
|
||
$token = $_POST['csrf_token'] ?? '';
|
||
if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
|
||
lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]);
|
||
}
|
||
// Token gilt für die ganze Sitzung (keine Erneuerung pro Benutzung, sonst scheitern parallel geöffnete Tabs)
|
||
}
|
||
|
||
[$deptFilterSql, $deptFilterParams] = dept_filter_sql($myDeptIds, 't.department_id');
|
||
|
||
// Sperre gegen versehentliches gleichzeitiges Antworten zweier Mitarbeiter im selben
|
||
// Chat (auch mit der Mobile-App geteilt, siehe live_chat_locked_by_other() in
|
||
// includes/functions.php): wer den Chat gerade offen hat, "besitzt" ihn für ein paar
|
||
// Minuten. Andere Mitarbeiter können den Chat trotzdem öffnen und live mitlesen
|
||
// (bewusst kein Redirect wie bei der normalen Ticketsperre in ticket_view.php – beim
|
||
// Live-Chat will man durchaus mitschauen können), das Schreiben wird ihnen aber sowohl
|
||
// in der Oberfläche als auch serverseitig (s.u.) verwehrt.
|
||
function lc_locked_by_other(array $ticket, array $staff, PDO $pdo): ?string
|
||
{
|
||
return live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
|
||
}
|
||
|
||
// ── AJAX: Nachrichten pollen ────────────────────────────────────────────────
|
||
if (($_GET['action'] ?? '') === 'poll') {
|
||
$number = $_GET['n'] ?? '';
|
||
$since = (int) ($_GET['since'] ?? 0);
|
||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||
$ticket = $stmt->fetch();
|
||
if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden.']);
|
||
|
||
$msgStmt = $pdo->prepare(
|
||
'SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name
|
||
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
|
||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 AND tt.id > ?
|
||
ORDER BY tt.created_at ASC, tt.id ASC'
|
||
);
|
||
$msgStmt->execute([$ticket['id'], $since]);
|
||
$lcAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
|
||
$polledMessages = array_map(function (array $m) use ($lcAttStmt) {
|
||
$lcAttStmt->execute([$m['id']]);
|
||
$m['attachments'] = $lcAttStmt->fetchAll();
|
||
return $m;
|
||
}, $msgStmt->fetchAll());
|
||
$customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time();
|
||
lc_json(['status' => $ticket['status'], 'customerTyping' => $customerTyping, 'messages' => $polledMessages]);
|
||
}
|
||
|
||
// ── AJAX: "Ich tippe gerade" (für den Tippt-Indikator beim Kunden) ─────────────
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'typing') {
|
||
// Token wird geprüft, aber bewusst NICHT rotiert (wie im Kunden-Widget, siehe
|
||
// live_chat.php): feuert bei jedem Tastenanschlag (gedrosselt), eine Rotation würde
|
||
// sonst mit einem parallel abgeschickten "send" um denselben Token wettlaufen.
|
||
$submittedCsrf = $_POST['csrf_token'] ?? '';
|
||
if (!$submittedCsrf || !hash_equals($_SESSION['csrf_token'] ?? '', $submittedCsrf)) {
|
||
lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]);
|
||
}
|
||
$number = $_POST['n'] ?? '';
|
||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||
$ticket = $stmt->fetch();
|
||
if ($ticket && $ticket['status'] !== 'closed' && !lc_locked_by_other($ticket, $staff, $pdo)) {
|
||
$pdo->prepare('UPDATE tickets SET typing_staff_until = ?, updated_at = updated_at WHERE id = ?')
|
||
->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
|
||
}
|
||
lc_json(['ok' => true]);
|
||
}
|
||
|
||
// ── AJAX: Chat-Liste pollen (für Live-Aktualisierung + Ton-Benachrichtigung) ────
|
||
if (($_GET['action'] ?? '') === 'list') {
|
||
$listStmt = $pdo->prepare(
|
||
"SELECT t.ticket_number, t.requester_name, t.updated_at,
|
||
(SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_author,
|
||
(SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_message
|
||
FROM tickets t
|
||
WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
|
||
ORDER BY t.updated_at DESC LIMIT 100"
|
||
);
|
||
$listStmt->execute($deptFilterParams);
|
||
lc_json(['items' => array_map(fn(array $c) => [
|
||
'number' => $c['ticket_number'],
|
||
'name' => $c['requester_name'],
|
||
'preview' => mb_substr(strip_tags((string) $c['last_message']), 0, 60),
|
||
'needsReply' => $c['last_author'] === 'customer',
|
||
'updatedAt' => $c['updated_at'],
|
||
], $listStmt->fetchAll())]);
|
||
}
|
||
|
||
// ── AJAX: Chat beenden ───────────────────────────────────────────────────────
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'end') {
|
||
lc_verify_csrf();
|
||
$number = $_POST['n'] ?? '';
|
||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||
$ticket = $stmt->fetch();
|
||
if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']);
|
||
if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) {
|
||
lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]);
|
||
}
|
||
|
||
$now = db_now();
|
||
$pdo->prepare(
|
||
"UPDATE tickets SET status='closed', resolution_code='other', resolution_note='Live-Chat beendet', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||
)->execute([$now, $now, $ticket['id']]);
|
||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', (int) $staff['id']);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat beendet');
|
||
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
|
||
|
||
// Mitarbeiter nicht auf den SMTP-Versand warten lassen – Antwort sofort raus,
|
||
// Chatverlauf-Mail danach im Hintergrund (abschaltbar unter Einstellungen →
|
||
// Allgemein, siehe notify_customer_chat_transcript() in includes/mailer.php).
|
||
lc_flush_response(['ok' => true]);
|
||
|
||
$transcriptStmt = $pdo->prepare(
|
||
'SELECT tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staff_name
|
||
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
|
||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC'
|
||
);
|
||
$transcriptStmt->execute([$ticket['id']]);
|
||
$transcriptMessages = array_map(fn(array $m) => [
|
||
'authorType' => $m['authorType'],
|
||
'authorName' => $m['staff_name'],
|
||
'message' => $m['message'],
|
||
'createdAt' => $m['createdAt'],
|
||
], $transcriptStmt->fetchAll());
|
||
if ($ticket['requester_email']) {
|
||
try {
|
||
notify_customer_chat_transcript($ticket, $transcriptMessages);
|
||
} catch (Throwable $e) {
|
||
error_log('live_chat transcript mail: ' . $e->getMessage());
|
||
}
|
||
}
|
||
exit;
|
||
}
|
||
|
||
// ── AJAX: Antwort senden ────────────────────────────────────────────────────
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
lc_verify_csrf();
|
||
$number = $_POST['n'] ?? '';
|
||
$message = sanitize_staff_html(trim($_POST['message'] ?? ''));
|
||
|
||
$stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||
$stmt->execute(array_merge([$number], $deptFilterParams));
|
||
$ticket = $stmt->fetch();
|
||
if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']);
|
||
if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) {
|
||
lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]);
|
||
}
|
||
if (trim(strip_tags($message)) === '') lc_json(['error' => 'Bitte eine Nachricht eingeben.']);
|
||
|
||
$now = db_now();
|
||
$pdo->prepare(
|
||
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
|
||
)->execute([$ticket['id'], 'staff', $staff['id'], $message, $now]);
|
||
$threadId = (int) $pdo->lastInsertId();
|
||
|
||
$autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === '');
|
||
$newStatus = 'answered';
|
||
$updateFirstResponse = $ticket['first_responded_at'] === null;
|
||
$stmt = $pdo->prepare(
|
||
'UPDATE tickets SET status = ?, updated_at = ?' .
|
||
($updateFirstResponse ? ', first_responded_at = ?' : '') .
|
||
($autoAssign ? ', assigned_staff_id = ?' : '') .
|
||
' WHERE id = ?'
|
||
);
|
||
$args = [$newStatus, $now];
|
||
if ($updateFirstResponse) $args[] = $now;
|
||
if ($autoAssign) $args[] = $staff['id'];
|
||
$args[] = $ticket['id'];
|
||
$stmt->execute($args);
|
||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']);
|
||
if ($autoAssign) log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (Live-Chat)');
|
||
|
||
save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
|
||
log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat-Antwort gesendet');
|
||
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
|
||
|
||
lc_json(['ok' => true, 'id' => $threadId, 'createdAt' => $now, 'status' => $newStatus]);
|
||
}
|
||
|
||
// ── Normale Seite ────────────────────────────────────────────────────────────
|
||
$listSql = "SELECT t.*, d.name AS department_name,
|
||
(SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_author,
|
||
(SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_message
|
||
FROM tickets t JOIN departments d ON d.id = t.department_id
|
||
WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
|
||
ORDER BY t.updated_at DESC LIMIT 100";
|
||
$listStmt = $pdo->prepare($listSql);
|
||
$listStmt->execute($deptFilterParams);
|
||
$chats = $listStmt->fetchAll();
|
||
|
||
$activeNumber = $_GET['n'] ?? ($chats[0]['ticket_number'] ?? null);
|
||
$activeTicket = null;
|
||
$activeMessages = [];
|
||
if ($activeNumber) {
|
||
foreach ($chats as $c) {
|
||
if ($c['ticket_number'] === $activeNumber) { $activeTicket = $c; break; }
|
||
}
|
||
if (!$activeTicket) {
|
||
// Ticket ggf. außerhalb der aktiven Liste (z.B. gerade geschlossen) – direkt laden
|
||
$stmt = $pdo->prepare('SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id=t.department_id WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : ''));
|
||
$stmt->execute(array_merge([$activeNumber], $deptFilterParams));
|
||
$activeTicket = $stmt->fetch() ?: null;
|
||
}
|
||
if ($activeTicket) {
|
||
$msgStmt = $pdo->prepare(
|
||
'SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name
|
||
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
|
||
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC'
|
||
);
|
||
$msgStmt->execute([$activeTicket['id']]);
|
||
$activeMessages = $msgStmt->fetchAll();
|
||
$lcInitAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
|
||
foreach ($activeMessages as &$__m) {
|
||
$lcInitAttStmt->execute([$__m['id']]);
|
||
$__m['attachments'] = $lcInitAttStmt->fetchAll();
|
||
}
|
||
unset($__m);
|
||
}
|
||
}
|
||
|
||
// Sperre übernehmen/auffrischen bzw. feststellen, dass ein anderer Mitarbeiter gerade
|
||
// aktiv dran ist (siehe lc_locked_by_other() oben).
|
||
$lockedByOtherName = null;
|
||
if ($activeTicket && $activeTicket['status'] !== 'closed') {
|
||
$lockedByOtherName = lc_locked_by_other($activeTicket, $staff, $pdo);
|
||
if (!$lockedByOtherName) {
|
||
live_chat_acquire_lock((int) $activeTicket['id'], (int) $staff['id'], $pdo);
|
||
}
|
||
}
|
||
|
||
$pageTitle = 'Live-Chat';
|
||
$activePage = 'live_chat';
|
||
require __DIR__ . '/_header.php';
|
||
?>
|
||
|
||
<?php if (get_setting('live_chat_enabled', '0') !== '1'): ?>
|
||
<h1>💬 Live-Chat</h1>
|
||
<div class="flash info">Live-Chat ist derzeit deaktiviert. Aktivieren unter <a href="/staff/settings.php?tab=allgemein">Einstellungen → Allgemein</a>.</div>
|
||
<?php else: ?>
|
||
|
||
<style>
|
||
.lc-wrap{display:flex;height:calc(100vh - 56px);margin:-32px -28px -60px;overflow:hidden;background:#fff;border-top:1px solid #e5e7eb}
|
||
.lc-list{width:280px;flex-shrink:0;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;background:#fff;overflow-y:auto}
|
||
.lc-list-title{padding:14px 16px;font-size:.72rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9ca3af;border-bottom:1px solid #e5e7eb;flex-shrink:0}
|
||
.lc-item{display:block;padding:11px 16px;text-decoration:none;border-bottom:1px solid #f3f4f6;cursor:pointer}
|
||
.lc-item:hover{background:#f9fafb}
|
||
.lc-item.on{background:#f0fdf4}
|
||
.lc-item-top{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||
.lc-item-name{font-size:.85rem;font-weight:600;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.lc-item-time{font-size:.68rem;color:#9ca3af;flex-shrink:0}
|
||
.lc-item-preview{font-size:.76rem;color:#9ca3af;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.lc-item.needs-reply .lc-item-preview{color:#111827;font-weight:600}
|
||
.lc-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--danger);margin-right:5px}
|
||
.lc-main{flex:1;display:flex;flex-direction:column;min-width:0;background:#fff}
|
||
.lc-topbar{padding:12px 18px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;gap:12px;background:#fff;flex-shrink:0}
|
||
.lc-msgs{flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:0;background:#f7f8fa}
|
||
.lc-msg{display:flex;padding:4px 0}
|
||
.lc-msg.me{justify-content:flex-end}
|
||
.lc-msg.them{justify-content:flex-start}
|
||
.lc-msg-col{display:flex;flex-direction:column;min-width:0;max-width:100%}
|
||
.lc-bubble{display:inline-block;padding:8px 12px;border-radius:14px;font-size:.87rem;line-height:1.45;word-break:break-word;max-width:520px;white-space:pre-wrap}
|
||
.lc-msg.me .lc-bubble{background:var(--primary);color:#fff}
|
||
.lc-msg.them .lc-bubble{background:#fff;border:1px solid #e5e7eb;color:#1f2937}
|
||
.lc-time{font-size:.65rem;color:#9ca3af;margin-top:2px;text-align:right}
|
||
.lc-inp-wrap{border-top:1px solid #e5e7eb;padding:10px 14px;display:flex;align-items:center;gap:10px;background:#fff;flex-shrink:0}
|
||
.lc-inp{flex:1;border:1px solid #d1d5db;border-radius:8px;background:#fff;font-size:.88rem;font-family:var(--font-body);color:#111827;outline:none;padding:8px 12px}
|
||
.lc-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#9ca3af;gap:10px}
|
||
</style>
|
||
|
||
<div class="lc-wrap">
|
||
<div class="lc-list">
|
||
<div class="lc-list-title" id="lc-list-title">Aktive Live-Chats (<?= count($chats) ?>)</div>
|
||
<div id="lc-chat-list">
|
||
<?php if (!$chats): ?>
|
||
<div style="padding:20px 16px;color:#9ca3af;font-size:.82rem" id="lc-empty-hint">Aktuell keine offenen Live-Chats.</div>
|
||
<?php endif; ?>
|
||
<?php foreach ($chats as $c): $needsReply = $c['last_author'] === 'customer'; ?>
|
||
<a href="?n=<?= urlencode($c['ticket_number']) ?>" class="lc-item <?= $c['ticket_number']===$activeNumber?'on':'' ?> <?= $needsReply?'needs-reply':'' ?>" data-number="<?= e($c['ticket_number']) ?>">
|
||
<div class="lc-item-top">
|
||
<span class="lc-item-name"><?= $needsReply ? '<span class="lc-dot"></span>' : '' ?><?= e($c['requester_name']) ?></span>
|
||
<span class="lc-item-time"><?= date('H:i', strtotime($c['updated_at'])) ?></span>
|
||
</div>
|
||
<div class="lc-item-preview"><?= e(mb_substr(strip_tags((string)$c['last_message']), 0, 60)) ?></div>
|
||
</a>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lc-main">
|
||
<?php if (!$activeTicket): ?>
|
||
<div class="lc-empty">
|
||
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="#d1d5db" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
<div style="font-size:.9rem;font-weight:600;color:#374151">Kein Live-Chat ausgewählt</div>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="lc-topbar">
|
||
<div>
|
||
<div style="font-weight:700;font-size:.93rem;color:#111827;line-height:1"><?= e($activeTicket['requester_name']) ?></div>
|
||
<div style="font-size:.74rem;color:#9ca3af;margin-top:2px"><?= e($activeTicket['requester_email']) ?> · <?= e($activeTicket['department_name']) ?></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<?php if ($activeTicket['status'] !== 'closed' && !$lockedByOtherName): ?>
|
||
<button type="button" id="lc-end-btn" class="btn secondary" style="font-size:.78rem;padding:5px 12px;color:var(--danger, #dc2626);border-color:var(--danger, #dc2626)">Chat beenden</button>
|
||
<?php endif; ?>
|
||
<a href="/staff/ticket_view.php?n=<?= urlencode($activeTicket['ticket_number']) ?>" class="btn secondary" style="font-size:.78rem;padding:5px 12px">Im Ticket öffnen ↗</a>
|
||
</div>
|
||
</div>
|
||
<?php if ($lockedByOtherName): ?>
|
||
<div style="padding:8px 16px;font-size:.78rem;background:#fffbeb;border-bottom:1px solid #fde68a;color:#92400e;display:flex;align-items:center;gap:6px">
|
||
🔒 Wird gerade von <strong> <?= e($lockedByOtherName) ?> </strong> bearbeitet – Sie sehen live mit, können hier aber nicht schreiben.
|
||
</div>
|
||
<?php endif; ?>
|
||
<div id="lc-messages" class="lc-msgs">
|
||
<?php foreach ($activeMessages as $m): $isMe = $m['author_type'] === 'staff'; ?>
|
||
<div class="lc-msg <?= $isMe?'me':'them' ?>" data-id="<?= $m['id'] ?>">
|
||
<div class="lc-msg-col">
|
||
<?php if (trim($m['message']) !== ''): ?>
|
||
<div class="lc-bubble"><?= nl2br(e($m['message'])) ?></div>
|
||
<?php endif; ?>
|
||
<?php foreach ($m['attachments'] as $a): ?>
|
||
<a href="/download.php?id=<?= $a['id'] ?>" target="_blank" style="display:inline-flex;align-items:center;gap:5px;font-size:.78rem;padding:5px 10px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;text-decoration:none;color:#1f2937;margin-bottom:4px">📎 <?= e($a['original_name']) ?></a>
|
||
<?php endforeach; ?>
|
||
<div class="lc-time"><?= date('H:i', strtotime($m['created_at'])) ?></div>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<div id="lc-typing" style="display:none;padding:0 16px 6px;font-size:.76rem;color:#9ca3af;font-style:italic;background:#fff">Kunde tippt …</div>
|
||
<?php if ($activeTicket['status'] === 'closed'): ?>
|
||
<div style="padding:12px 16px;font-size:.8rem;color:#9ca3af;text-align:center;border-top:1px solid #e5e7eb">Dieser Chat wurde geschlossen.</div>
|
||
<?php elseif ($lockedByOtherName): ?>
|
||
<div style="padding:12px 16px;font-size:.8rem;color:#9ca3af;text-align:center;border-top:1px solid #e5e7eb">Nur Lesezugriff, solange <?= e($lockedByOtherName) ?> diesen Chat bearbeitet.</div>
|
||
<?php else: ?>
|
||
<div class="lc-inp-wrap">
|
||
<input type="text" id="lc-input" class="lc-inp" placeholder="Nachricht schreiben…">
|
||
<button type="button" class="btn" id="lc-send-btn">Senden</button>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
// Kurzer Hinweiston per Web Audio API (kein Audio-Asset nötig, funktioniert überall).
|
||
window.lcPlayNotifySound = function () {
|
||
try {
|
||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||
var osc = ctx.createOscillator();
|
||
var gain = ctx.createGain();
|
||
osc.connect(gain); gain.connect(ctx.destination);
|
||
osc.frequency.value = 880;
|
||
gain.gain.setValueAtTime(0.15, ctx.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.35);
|
||
osc.start(); osc.stop(ctx.currentTime + 0.35);
|
||
} catch (e) {}
|
||
};
|
||
|
||
var known = {}; // ticket_number -> needsReply, aus dem letzten Abruf
|
||
var firstRun = true;
|
||
var activeNumber = <?= json_encode($activeTicket['ticket_number'] ?? null) ?>;
|
||
|
||
function renderList(items) {
|
||
var list = document.getElementById('lc-chat-list');
|
||
document.getElementById('lc-list-title').textContent = 'Aktive Live-Chats (' + items.length + ')';
|
||
if (!items.length) {
|
||
list.innerHTML = '<div style="padding:20px 16px;color:#9ca3af;font-size:.82rem">Aktuell keine offenen Live-Chats.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = '';
|
||
items.forEach(function (it) {
|
||
var a = document.createElement('a');
|
||
a.href = '?n=' + encodeURIComponent(it.number);
|
||
a.className = 'lc-item' + (it.number === activeNumber ? ' on' : '') + (it.needsReply ? ' needs-reply' : '');
|
||
var top = document.createElement('div');
|
||
top.className = 'lc-item-top';
|
||
var nameSpan = document.createElement('span');
|
||
nameSpan.className = 'lc-item-name';
|
||
if (it.needsReply) {
|
||
var dot = document.createElement('span');
|
||
dot.className = 'lc-dot';
|
||
nameSpan.appendChild(dot);
|
||
}
|
||
nameSpan.appendChild(document.createTextNode(it.name));
|
||
var timeSpan = document.createElement('span');
|
||
timeSpan.className = 'lc-item-time';
|
||
timeSpan.textContent = (it.updatedAt || '').substring(11, 16);
|
||
top.appendChild(nameSpan); top.appendChild(timeSpan);
|
||
var preview = document.createElement('div');
|
||
preview.className = 'lc-item-preview';
|
||
preview.textContent = it.preview;
|
||
a.appendChild(top); a.appendChild(preview);
|
||
list.appendChild(a);
|
||
});
|
||
}
|
||
|
||
function pollList() {
|
||
fetch('/staff/live_chat.php?action=list')
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (!data.items) return;
|
||
var newlyNeedsReply = false;
|
||
data.items.forEach(function (it) {
|
||
var wasKnown = Object.prototype.hasOwnProperty.call(known, it.number);
|
||
if (it.needsReply && (!wasKnown || !known[it.number])) {
|
||
// Neue oder neu unbeantwortete Konversation – aber beim allerersten Laden
|
||
// (firstRun) nicht gleich losklingeln, sonst piept es bei jedem Seitenaufruf.
|
||
if (!firstRun) newlyNeedsReply = true;
|
||
}
|
||
known[it.number] = it.needsReply;
|
||
});
|
||
renderList(data.items);
|
||
if (newlyNeedsReply) window.lcPlayNotifySound();
|
||
firstRun = false;
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
|
||
pollList();
|
||
setInterval(pollList, 8000);
|
||
})();
|
||
</script>
|
||
|
||
<?php if ($activeTicket): ?>
|
||
<script>
|
||
(function () {
|
||
var number = <?= json_encode($activeTicket['ticket_number']) ?>;
|
||
var csrf = <?= json_encode(csrf_token()) ?>;
|
||
var lastId = <?= $activeMessages ? (int) end($activeMessages)['id'] : 0 ?>;
|
||
var messagesEl = document.getElementById('lc-messages');
|
||
var typingEl = document.getElementById('lc-typing');
|
||
var input = document.getElementById('lc-input');
|
||
var sendBtn = document.getElementById('lc-send-btn');
|
||
var endBtn = document.getElementById('lc-end-btn');
|
||
var lastTypingPingAt = 0;
|
||
|
||
function scrollBottom() { messagesEl.scrollTop = messagesEl.scrollHeight; }
|
||
scrollBottom();
|
||
|
||
function pingTyping() {
|
||
var now = Date.now();
|
||
if (now - lastTypingPingAt < 3000) return;
|
||
lastTypingPingAt = now;
|
||
var body = new URLSearchParams();
|
||
body.set('action', 'typing');
|
||
body.set('n', number);
|
||
body.set('csrf_token', csrf);
|
||
fetch('/staff/live_chat.php', { method: 'POST', body: body }).catch(function () {});
|
||
}
|
||
if (input) input.addEventListener('input', pingTyping);
|
||
|
||
function appendBubble(m, isMe) {
|
||
var row = document.createElement('div');
|
||
row.className = 'lc-msg ' + (isMe ? 'me' : 'them');
|
||
var col = document.createElement('div');
|
||
col.className = 'lc-msg-col';
|
||
if (m.message) {
|
||
var bubble = document.createElement('div');
|
||
bubble.className = 'lc-bubble';
|
||
bubble.textContent = m.message;
|
||
col.appendChild(bubble);
|
||
}
|
||
(m.attachments || []).forEach(function (a) {
|
||
var link = document.createElement('a');
|
||
link.href = '/download.php?id=' + a.id;
|
||
link.target = '_blank';
|
||
link.style.cssText = 'display:inline-flex;align-items:center;gap:5px;font-size:.78rem;padding:5px 10px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:8px;text-decoration:none;color:#1f2937;margin-bottom:4px';
|
||
link.textContent = '📎 ' + a.name;
|
||
col.appendChild(link);
|
||
});
|
||
var time = document.createElement('div');
|
||
time.className = 'lc-time';
|
||
time.textContent = (m.created_at || '').substring(11, 16);
|
||
col.appendChild(time);
|
||
row.appendChild(col);
|
||
messagesEl.appendChild(row);
|
||
scrollBottom();
|
||
}
|
||
|
||
function updateCsrf(data) {
|
||
if (data && data.csrfToken) csrf = data.csrfToken;
|
||
}
|
||
|
||
function send() {
|
||
var text = input.value.trim();
|
||
if (!text) return;
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
var body = new URLSearchParams();
|
||
body.set('n', number);
|
||
body.set('message', text);
|
||
body.set('csrf_token', csrf);
|
||
fetch('/staff/live_chat.php', { method: 'POST', body: body })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
updateCsrf(data);
|
||
// Eingabe erst NACH bestätigtem Erfolg leeren – schlägt das Senden fehl
|
||
// (abgelaufener Token, Netzwerkfehler), bleibt der getippte Text erhalten
|
||
// statt kommentarlos verloren zu gehen.
|
||
if (data.error) { alert(data.error); return; }
|
||
input.value = '';
|
||
appendBubble({ message: text, created_at: data.createdAt }, true);
|
||
lastId = data.id;
|
||
})
|
||
.catch(function () { alert('Senden fehlgeschlagen. Bitte erneut versuchen.'); })
|
||
.finally(function () { if (sendBtn) sendBtn.disabled = false; });
|
||
}
|
||
|
||
if (sendBtn) sendBtn.addEventListener('click', send);
|
||
if (input) input.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||
});
|
||
|
||
if (endBtn) endBtn.addEventListener('click', function () {
|
||
if (!confirm('Diesen Chat wirklich beenden? Er bleibt als Ticket erhalten, verschwindet aber aus den aktiven Live-Chats.')) return;
|
||
var body = new URLSearchParams();
|
||
body.set('action', 'end');
|
||
body.set('n', number);
|
||
body.set('csrf_token', csrf);
|
||
fetch('/staff/live_chat.php', { method: 'POST', body: body })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
updateCsrf(data);
|
||
if (data.error) { alert(data.error); return; }
|
||
window.location.href = '/staff/live_chat.php';
|
||
})
|
||
.catch(function () { alert('Beenden fehlgeschlagen. Bitte erneut versuchen.'); });
|
||
});
|
||
|
||
function poll() {
|
||
fetch('/staff/live_chat.php?action=poll&n=' + encodeURIComponent(number) + '&since=' + lastId)
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
if (data.error) return;
|
||
if (typingEl) typingEl.style.display = data.customerTyping ? 'block' : 'none';
|
||
if (!data.messages) return;
|
||
data.messages.forEach(function (m) {
|
||
appendBubble(m, m.author_type === 'staff');
|
||
if (m.author_type === 'customer' && window.lcPlayNotifySound) window.lcPlayNotifySound();
|
||
if (m.id > lastId) lastId = m.id;
|
||
});
|
||
})
|
||
.catch(function () {});
|
||
}
|
||
setInterval(poll, 8000);
|
||
})();
|
||
</script>
|
||
<?php endif; ?>
|
||
|
||
<?php endif; ?>
|
||
|
||
<?php require __DIR__ . '/_footer.php'; ?>
|