Files
2026-11-01 11:15:44 +00:00

617 lines
31 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 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>&nbsp;<?= e($lockedByOtherName) ?>&nbsp;</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'; ?>