Files
whatsapp/modules/turnero/views/chat.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 dc5fd6e272 feat(chat): separadores de fecha estilo WhatsApp
Muestra "Hoy", "Ayer" o la fecha entre grupos de mensajes de días distintos.
El timestamp del bubble ahora solo muestra la hora.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 09:58:08 -05:00

1489 lines
68 KiB
PHP

<?php
/**
* modules/turnero/views/chat.php
* Inbox del número WhatsApp del Turnero.
* Permite ver y responder mensajes de pacientes que escriben al número turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
// URL de regreso: recepcionista → su desk por IP o primer desk; resto → dashboard
$_chatBackUrl = BASE_URL . 'erp.php?m=turnero&v=dashboard';
if (($_SESSION['admin_user']['role'] ?? '') === 'recepcionista') {
try {
$pdo = Database::getInstance()->getConnection();
$chatIp = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]);
// 1. Buscar desk por IP
$st = $pdo->prepare(
"SELECT d.lugar_id FROM turnero_dispositivos d
JOIN turnero_lugares l ON l.id = d.lugar_id
WHERE d.ip = ? AND d.activo = 1 AND l.tipo = 'recepcion' LIMIT 1"
);
$st->execute([$chatIp]);
$lugId = $st->fetchColumn();
// 2. Fallback: primer desk de recepción activo
if (!$lugId) {
$lugId = $pdo->query(
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='recepcion' ORDER BY sort_order LIMIT 1"
)->fetchColumn();
}
if ($lugId) {
$_chatBackUrl = BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . (int)$lugId;
}
} catch (\Throwable $_) {}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
<title>Chat Turnero — WhatsApp</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
.chat-shell { display: flex; height: 100%; }
/* ── Sidebar ── */
.sidebar {
width: 340px; min-width: 280px; max-width: 380px;
background: #fff; border-right: 1px solid #e9ecef;
display: flex; flex-direction: column;
}
.sidebar-header {
padding: 16px; background: #25d366; color: #fff;
display: flex; align-items: center; gap: 10px;
}
.sidebar-header h2 { font-size: 1rem; flex: 1; }
.sidebar-header .badge-config {
font-size: .7rem; background: rgba(0,0,0,.2); padding: 3px 8px; border-radius: 12px;
}
.btn-nuevo-chat {
background: rgba(255,255,255,.2); border: none; color: #fff; border-radius: 8px;
padding: 5px 10px; cursor: pointer; font-size: .82rem; font-weight: 700;
display: flex; align-items: center; gap: 4px; transition: background .15s; flex-shrink: 0;
}
.btn-nuevo-chat:hover { background: rgba(255,255,255,.35); }
.search-box { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
.search-box input {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
border-radius: 20px; font-size: .85rem; outline: none;
background: #f5f5f5;
}
.search-box input:focus { border-color: #25d366; background: #fff; }
.contact-list { flex: 1; overflow-y: auto; }
.contact-item {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #f7f7f7;
transition: background .15s;
}
.contact-item:hover { background: #f5f5f5; }
.contact-item.active { background: #e7fce8; }
.contact-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: #25d366; color: #fff; display: flex; align-items: center;
justify-content: center; font-weight: 700; font-size: 1.1rem; flex-shrink: 0;
}
.contact-info { flex: 1; min-width: 0; }
.contact-name { font-weight: 600; font-size: .9rem; color: #111; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.contact-last { font-size: .78rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.contact-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
.contact-time { font-size: .7rem; color: #aaa; }
.badge-unread {
background: #25d366; color: #fff; font-size: .65rem; font-weight: 700;
padding: 2px 6px; border-radius: 10px; min-width: 18px; text-align: center;
}
.contact-empty { text-align: center; padding: 40px 16px; color: #aaa; font-size: .85rem; }
/* ── Chat window ── */
.chat-window {
flex: 1; display: flex; flex-direction: column;
background: #efeae2;
}
.chat-empty-state {
flex: 1; display: flex; flex-direction: column; align-items: center;
justify-content: center; color: #aaa; gap: 12px;
}
.chat-empty-state i { font-size: 3rem; color: #25d366; }
.chat-empty-state p { font-size: .95rem; }
.chat-topbar {
background: #fff; padding: 12px 16px;
display: flex; align-items: center; gap: 12px;
border-bottom: 1px solid #e9ecef; min-height: 60px;
position: sticky; top: 0; z-index: 10; flex-shrink: 0;
}
.chat-topbar-avatar {
width: 38px; height: 38px; border-radius: 50%;
background: #25d366; color: #fff; display: flex;
align-items: center; justify-content: center; font-weight: 700;
}
.chat-topbar-info h3 { font-size: .95rem; font-weight: 600; }
.chat-topbar-info span { font-size: .78rem; color: #888; }
.messages-area {
flex: 1; overflow-y: auto; padding: 12px 16px;
display: flex; flex-direction: column; gap: 6px;
min-height: 0; -webkit-overflow-scrolling: touch;
}
.msg-bubble {
max-width: 65%; padding: 8px 12px; border-radius: 8px;
font-size: .88rem; line-height: 1.4; position: relative;
word-break: break-word;
}
.msg-bubble.incoming {
background: #fff; align-self: flex-start;
border-bottom-left-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.msg-bubble.outgoing {
background: #d9fdd3; align-self: flex-end;
border-bottom-right-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
}
.msg-time {
font-size: .68rem; color: #aaa; text-align: right; margin-top: 3px;
}
.date-sep {
align-self: center; background: #e9ecef; color: #6c757d;
font-size: .73rem; font-weight: 600; padding: 3px 12px;
border-radius: 99px; margin: 10px 0; user-select: none;
}
.msg-type-badge {
font-size: .7rem; color: #888; font-style: italic;
}
.msg-media-thumb {
max-width: 220px; max-height: 180px; border-radius: 6px;
display: block; margin-bottom: 4px; cursor: pointer;
}
.msg-media-link {
display: flex; align-items: center; gap: 6px; font-size: .82rem; color: #1a73e8;
text-decoration: none; padding: 4px 0;
}
.msg-media-link:hover { text-decoration: underline; }
.load-more-btn {
align-self: center; padding: 6px 18px; font-size: .8rem;
background: #fff; border: 1px solid #ddd; border-radius: 16px;
cursor: pointer; color: #555;
}
.load-more-btn:hover { background: #f5f5f5; }
/* ── Input area ── */
.chat-input-area {
background: #f0f2f5; padding: 10px 16px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
display: flex; align-items: flex-end; gap: 10px;
border-top: 1px solid #e0e0e0; flex-shrink: 0;
}
.chat-input-area textarea {
flex: 1; padding: 10px 14px; border: none; border-radius: 22px;
background: #fff; font-size: .9rem; resize: none; outline: none;
max-height: 120px; line-height: 1.4;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.btn-send {
width: 42px; height: 42px; border-radius: 50%; border: none;
background: #25d366; color: #fff; font-size: 1.1rem;
cursor: pointer; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: background .2s;
}
.btn-send:hover { background: #1ebe5c; }
.btn-send:disabled { background: #ccc; cursor: default; }
/* ── Alertas ── */
.alert-no-config {
background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px;
padding: 12px 16px; margin: 16px; font-size: .85rem; color: #856404;
display: flex; align-items: center; gap: 8px;
}
.typing-indicator { font-size: .75rem; color: #888; padding: 2px 8px; }
/* ── Botones extra en input ── */
.input-extra-btn {
background: none; border: none; color: #888; font-size: 1.15rem;
cursor: pointer; padding: 6px; border-radius: 50%; transition: background .15s, color .15s;
flex-shrink: 0;
}
.input-extra-btn:hover { background: rgba(0,0,0,.06); color: #555; }
/* ── Panel emoji ── */
.emoji-panel {
position: absolute; bottom: 64px; left: 0;
background: #fff; border: 1px solid #e0e0e0; border-radius: 12px;
box-shadow: 0 6px 24px rgba(0,0,0,.15); padding: 10px;
display: none; flex-wrap: wrap; gap: 2px; width: 280px; z-index: 50;
}
.emoji-panel.show { display: flex; }
.emoji-panel span { font-size: 1.3rem; cursor: pointer; padding: 4px; border-radius: 6px; }
.emoji-panel span:hover { background: #f0f2f5; }
/* ── Reaction picker ── */
#reaction-picker {
position: fixed; display: none; z-index: 9000;
background: #fff; border-radius: 30px; padding: 6px 10px;
box-shadow: 0 4px 20px rgba(0,0,0,.18);
gap: 4px; align-items: center;
}
#reaction-picker.show { display: flex; }
#reaction-picker span { font-size: 1.4rem; cursor: pointer; padding: 4px 6px; border-radius: 50%; transition: transform .1s; }
#reaction-picker span:hover { transform: scale(1.3); }
.msg-bubble { position: relative; }
.msg-bubble:hover .react-btn { opacity: 1; }
.react-btn {
position: absolute; top: 4px; opacity: 0; transition: opacity .15s;
background: #fff; border: 1px solid #e0e0e0; border-radius: 50%;
width: 24px; height: 24px; font-size: .75rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 1px 4px rgba(0,0,0,.12);
}
.msg-bubble.incoming .react-btn { right: -28px; }
.msg-bubble.outgoing .react-btn { left: -28px; }
.msg-reaction { font-size: .95rem; margin-top: 2px; }
/* ── Preview adjunto ── */
#attach-preview {
display: none; align-items: center; gap: 8px;
padding: 8px 12px; background: #e9f5fe;
border-top: 1px solid #bee3f8; font-size: .85rem;
}
#attach-preview img { width: 48px; height: 48px; object-fit: cover; border-radius: 6px; }
#attach-preview .attach-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#attach-preview .attach-remove { background: none; border: none; color: #e53e3e; cursor: pointer; font-size: 1rem; }
/* ── Recording indicator ── */
#recording-bar {
display: none; align-items: center; gap: 10px;
padding: 8px 14px; background: #fff0f0; border-top: 1px solid #fed7d7;
font-size: .85rem; color: #e53e3e;
}
#recording-bar .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #e53e3e; animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
/* ── Modal plantilla (2 pasos) ── */
#tpl-modal {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.45); align-items: center; justify-content: center; padding: 16px;
}
#tpl-modal.show { display: flex; }
.tpl-dialog { background: #fff; border-radius: 14px; width: 100%; max-width: 400px; box-shadow: 0 8px 32px rgba(0,0,0,.22); overflow: hidden; }
.tpl-hdr { display: flex; align-items: center; gap: 8px; padding: 13px 16px; border-bottom: 1px solid #f1f5f9; font-size: .92rem; font-weight: 700; color: #1e293b; }
.tpl-hdr > span { flex: 1; }
.tpl-close-btn { background: none; border: none; cursor: pointer; color: #94a3b8; font-size: .95rem; padding: 2px 7px; border-radius: 4px; }
.tpl-close-btn:hover { color: #475569; background: #f1f5f9; }
.tpl-back-btn { background: none; border: none; cursor: pointer; color: #64748b; font-size: .85rem; padding: 2px 7px; border-radius: 4px; flex-shrink: 0; }
.tpl-back-btn:hover { background: #f1f5f9; }
.tpl-item { display: flex; flex-direction: column; gap: 2px; padding: 9px 12px; cursor: pointer; border-radius: 8px; margin: 0 6px; transition: background .12s; }
.tpl-item:hover { background: #f0fdf4; }
.tpl-item-name { font-size: .87rem; font-weight: 600; color: #1e293b; }
.tpl-item-meta { font-size: .74rem; color: #64748b; }
.tpl-item-body { font-size: .74rem; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tpl-preview-box { background: #f0fdf4; border-left: 3px solid #25d366; border-radius: 0 6px 6px 0; padding: 8px 12px; font-size: .82rem; color: #374151; margin: 10px 16px 2px; white-space: pre-wrap; word-break: break-word; }
.tpl-var-row { padding: 0 16px; }
.tpl-var-row label { font-size: .78rem; font-weight: 600; color: #475569; display: block; margin-bottom: 3px; }
.tpl-var-row input { width: 100%; padding: 7px 10px; border: 1.5px solid #e2e8f0; border-radius: 8px; font-size: .87rem; outline: none; box-sizing: border-box; }
.tpl-var-row input:focus { border-color: #25d366; }
.tpl-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid #f1f5f9; }
.btn-tpl-cancel { padding: 8px 16px; background: #f5f5f5; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; }
.btn-tpl-send { padding: 8px 16px; background: #25d366; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; font-weight: 600; }
/* ── Media en burbuja ── */
.msg-img { max-width: 220px; max-height: 200px; border-radius: 8px; cursor: pointer; display: block; margin-bottom: 3px; }
.msg-video { max-width: 220px; border-radius: 8px; display: block; margin-bottom: 3px; }
.msg-audio { width: 200px; margin-bottom: 3px; }
.msg-doc-link { display: flex; align-items: center; gap: 7px; font-size: .83rem; color: #1a73e8; text-decoration: none; padding: 4px 0; }
.msg-doc-link:hover { text-decoration: underline; }
.back-btn { display: none; background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
/* ── Modal nuevo mensaje ── */
#nuevo-modal { display:none; position:fixed; inset:0; z-index:1100;
background:rgba(0,0,0,.45); align-items:center; justify-content:center; padding:16px; }
#nuevo-modal.show { display:flex; }
.nv-dialog { background:#fff; border-radius:14px; width:100%; max-width:420px;
box-shadow:0 8px 32px rgba(0,0,0,.22); overflow:hidden; display:flex; flex-direction:column; max-height:90vh; }
.nv-hdr { display:flex; align-items:center; gap:8px; padding:13px 16px;
border-bottom:1px solid #f1f5f9; font-size:.92rem; font-weight:700; color:#1e293b; flex-shrink:0; }
.nv-hdr > span { flex:1; }
.nv-body { padding:14px 16px; overflow-y:auto; flex:1; }
.nv-footer { display:flex; gap:8px; justify-content:flex-end; padding:12px 16px;
border-top:1px solid #f1f5f9; flex-shrink:0; }
.nv-search { width:100%; padding:8px 12px; border:1.5px solid #e2e8f0; border-radius:8px;
font-size:.87rem; outline:none; box-sizing:border-box; margin-bottom:10px; }
.nv-search:focus { border-color:#25d366; }
.nv-pac-item { display:flex; flex-direction:column; gap:1px; padding:9px 10px; cursor:pointer;
border-radius:8px; transition:background .12s; border:1px solid transparent; }
.nv-pac-item:hover { background:#f0fdf4; border-color:#bbf7d0; }
.nv-pac-name { font-size:.87rem; font-weight:600; color:#1e293b; }
.nv-pac-sub { font-size:.75rem; color:#64748b; }
.nv-divider { display:flex; align-items:center; gap:8px; margin:10px 0;
font-size:.75rem; color:#94a3b8; }
.nv-divider::before,.nv-divider::after { content:''; flex:1; height:1px; background:#e2e8f0; }
.nv-destinatario { display:flex; align-items:center; gap:8px; padding:8px 10px;
background:#f0fdf4; border:1px solid #bbf7d0; border-radius:8px; margin-bottom:10px; font-size:.85rem; }
.nv-destinatario strong { flex:1; color:#15803d; }
.nv-back-btn { background:none; border:none; cursor:pointer; color:#64748b;
font-size:.85rem; padding:2px 7px; border-radius:4px; }
.nv-back-btn:hover { background:#f1f5f9; }
.nv-close-btn { background:none; border:none; cursor:pointer; color:#94a3b8;
font-size:.95rem; padding:2px 7px; border-radius:4px; }
.nv-close-btn:hover { color:#475569; background:#f1f5f9; }
@media (max-width: 680px) {
.chat-shell { position: relative; overflow: hidden; }
.sidebar { position: absolute; inset: 0; z-index: 10; transition: transform .2s ease; }
.sidebar.mobile-hidden { transform: translateX(-100%); pointer-events: none; }
.chat-window { position: absolute; inset: 0; z-index: 5; transform: translateX(100%); height: 100%; transition: transform .2s ease; }
.chat-window.mobile-active { transform: translateX(0); }
.back-btn { display: flex; }
}
</style>
</head>
<body>
<div class="chat-shell">
<!-- ── Sidebar: lista de contactos ── -->
<div class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="<?= htmlspecialchars($_chatBackUrl) ?>"
style="color:rgba(255,255,255,.7);font-size:.85rem;text-decoration:none;display:flex;align-items:center;gap:5px;margin-bottom:8px">
<i class="fas fa-arrow-left"></i> Menú
</a>
<i class="fab fa-whatsapp" style="font-size:1.4rem"></i>
<h2>Chat Turnero</h2>
<?php if ($turneroPhoneId): ?>
<span class="badge-config" title="Número turnero configurado">
<i class="fas fa-check-circle"></i> Activo
</span>
<?php else: ?>
<span class="badge-config" style="background:rgba(220,53,69,.6)" title="Sin configurar">
<i class="fas fa-exclamation-circle"></i> Sin config
</span>
<?php endif; ?>
<button class="btn-nuevo-chat" onclick="nuevoMensaje.abrir()" title="Nuevo mensaje">
<i class="fas fa-plus"></i> Nuevo
</button>
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Buscar contacto…" autocomplete="off">
</div>
<div class="contact-list" id="contactList">
<div class="contact-empty"><i class="fas fa-spinner fa-spin"></i><br>Cargando…</div>
</div>
</div>
<!-- ── Ventana de chat ── -->
<div class="chat-window" id="chatWindow">
<?php if (!$turneroPhoneId): ?>
<div class="alert-no-config">
<i class="fas fa-exclamation-triangle"></i>
El número de WhatsApp del Turnero no está configurado.
<a href="/erp.php?m=turnero&v=configuracion" style="margin-left:8px;color:#856404;font-weight:600;">Configurar ahora</a>
</div>
<?php endif; ?>
<div class="chat-empty-state" id="emptyState">
<i class="fab fa-whatsapp"></i>
<p>Selecciona un contacto para ver la conversación</p>
</div>
<div id="activeChat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
<div class="chat-topbar" id="chatTopbar">
<button class="back-btn" onclick="closeChatMobile()"><i class="fas fa-arrow-left"></i></button>
<div class="chat-topbar-avatar" id="topbarAvatar">?</div>
<div class="chat-topbar-info">
<h3 id="topbarName">—</h3>
<span id="topbarPhone">—</span>
</div>
</div>
<div class="messages-area" id="messagesArea">
<button class="load-more-btn" id="loadMoreBtn" style="display:none" onclick="loadMoreMessages()">
<i class="fas fa-chevron-up"></i> Ver mensajes anteriores
</button>
</div>
<div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div>
<!-- Preview adjunto -->
<div id="attach-preview">
<img id="attachThumb" src="" alt="" style="display:none">
<span class="attach-name" id="attachName"></span>
<button class="attach-remove" onclick="clearAttach()">✕</button>
</div>
<!-- Barra de grabación -->
<div id="recording-bar">
<div class="rec-dot"></div>
<span>Grabando…</span>
<span id="recTimer" style="font-weight:700;min-width:32px">0:00</span>
<button onclick="stopRecording()" style="margin-left:auto;background:#e53e3e;color:#fff;border:none;border-radius:6px;padding:4px 12px;cursor:pointer;font-size:.82rem">Enviar</button>
<button onclick="cancelRecording()" style="background:#f5f5f5;border:none;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:.82rem">Cancelar</button>
</div>
<input type="file" id="fileInput" style="display:none"
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.csv"
onchange="onFileSelected(event)">
<div class="chat-input-area" style="position:relative">
<div class="emoji-panel" id="emojiPanel"></div>
<button class="input-extra-btn" onclick="toggleEmojiPanel()" title="Emoji"><i class="fas fa-smile"></i></button>
<button class="input-extra-btn" onclick="document.getElementById('fileInput').click()" title="Adjuntar"><i class="fas fa-paperclip"></i></button>
<button class="input-extra-btn" id="micBtn" onclick="toggleRecording()" title="Grabar audio"><i class="fas fa-microphone"></i></button>
<textarea id="msgInput" rows="1" placeholder="Escribe un mensaje…"
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="input-extra-btn" onclick="openTemplatePicker()" title="Plantilla"><i class="fas fa-file-alt"></i></button>
<button class="btn-send" id="btnSend" onclick="sendMessage()" <?= $turneroPhoneId ? '' : 'disabled' ?>>
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
</div><!-- /chat-window -->
</div><!-- /chat-shell -->
<!-- ── Reaction picker ── -->
<div id="reaction-picker">
<?php foreach (['👍','❤️','😂','😮','😢','🙏','🔥','👏'] as $em): ?>
<span onclick="sendReactionEmoji('<?= $em ?>')"><?= $em ?></span>
<?php endforeach; ?>
</div>
<!-- ── Modal nuevo mensaje (3 pasos) ── -->
<div id="nuevo-modal">
<!-- Paso 1: destinatario -->
<div class="nv-dialog" id="nv-step-dest">
<div class="nv-hdr">
<span><i class="fas fa-user-plus me-2" style="color:#25d366"></i>Nuevo mensaje</span>
<button class="nv-close-btn" onclick="nuevoMensaje.cerrar()">✕</button>
</div>
<div class="nv-body">
<input class="nv-search" id="nv-pac-search" placeholder="🔍 Buscar paciente por nombre o cédula…"
autocomplete="off" oninput="nuevoMensaje.buscarPaciente(this.value)">
<div id="nv-pac-results" style="display:flex;flex-direction:column;gap:4px;min-height:40px"></div>
<div class="nv-divider">o ingresa el número directamente</div>
<input class="nv-search" id="nv-phone-input" placeholder="📱 Número de WhatsApp (ej: 3001234567)"
autocomplete="off" inputmode="tel"
oninput="nuevoMensaje.onPhoneInput(this.value)">
<div id="nv-phone-hint" style="font-size:.76rem;color:#94a3b8;margin-top:-6px;margin-bottom:4px"></div>
</div>
<div class="nv-footer">
<button class="btn btn-outline-secondary btn-sm" onclick="nuevoMensaje.cerrar()">Cancelar</button>
<button class="btn btn-success btn-sm fw-semibold" id="nv-btn-siguiente"
onclick="nuevoMensaje.irAPlantilla()" disabled>
Siguiente <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
<!-- Paso 2: plantilla -->
<div class="nv-dialog" id="nv-step-tpl" style="display:none">
<div class="nv-hdr">
<button class="nv-back-btn" onclick="nuevoMensaje.volverADest()"><i class="fas fa-arrow-left"></i></button>
<span>Seleccionar plantilla</span>
<button class="nv-close-btn" onclick="nuevoMensaje.cerrar()">✕</button>
</div>
<div class="nv-body">
<div class="nv-destinatario">
<i class="fas fa-user-circle" style="color:#25d366;font-size:1.1rem"></i>
<strong id="nv-dest-label">—</strong>
</div>
<div id="nv-tpl-list" style="display:flex;flex-direction:column;gap:4px;overflow-y:auto;max-height:300px"></div>
</div>
</div>
<!-- Paso 3: variables -->
<div class="nv-dialog" id="nv-step-vars" style="display:none">
<div class="nv-hdr">
<button class="nv-back-btn" onclick="nuevoMensaje.volverAPlantilla()"><i class="fas fa-arrow-left"></i></button>
<span id="nv-vars-title">Completar variables</span>
<button class="nv-close-btn" onclick="nuevoMensaje.cerrar()">✕</button>
</div>
<div class="nv-body">
<div id="nv-preview-box" style="display:none;background:#f0fdf4;border-left:3px solid #25d366;
border-radius:0 6px 6px 0;padding:8px 12px;font-size:.82rem;color:#374151;
margin-bottom:10px;white-space:pre-wrap;word-break:break-word"></div>
<div id="nv-vars-body" style="display:flex;flex-direction:column;gap:.6rem"></div>
</div>
<div class="nv-footer">
<button class="btn btn-outline-secondary btn-sm" onclick="nuevoMensaje.volverAPlantilla()">Atrás</button>
<button class="btn btn-success btn-sm fw-semibold" onclick="nuevoMensaje.enviar()">
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
</div>
</div>
</div>
<!-- ── Modal plantilla (2 pasos) ── -->
<div id="tpl-modal">
<!-- Paso 1: lista -->
<div class="tpl-dialog" id="tpl-step-list">
<div class="tpl-hdr">
<span><i class="fas fa-file-alt" style="color:#25d366;margin-right:6px"></i>Seleccionar plantilla</span>
<button class="tpl-close-btn" onclick="closeTemplatePicker()">✕</button>
</div>
<div id="tpl-list-body" style="overflow-y:auto;max-height:360px;display:flex;flex-direction:column;gap:.25rem;padding:.5rem 0"></div>
</div>
<!-- Paso 2: variables -->
<div class="tpl-dialog" id="tpl-step-vars" style="display:none">
<div class="tpl-hdr">
<button class="tpl-back-btn" onclick="tplGoBack()"><i class="fas fa-arrow-left"></i></button>
<span id="tpl-vars-title">Completar variables</span>
<button class="tpl-close-btn" onclick="closeTemplatePicker()">✕</button>
</div>
<div id="tpl-preview-box" style="display:none" class="tpl-preview-box"></div>
<div id="tpl-vars-body" style="display:flex;flex-direction:column;gap:.6rem;padding:.6rem 0"></div>
<div class="tpl-actions">
<button class="btn-tpl-cancel" onclick="tplGoBack()">Atrás</button>
<button class="btn-tpl-send" onclick="sendTemplate()"><i class="fas fa-paper-plane me-1"></i>Enviar</button>
</div>
</div>
</div>
<script>
const API_LIST = 'modules/turnero/api/chat_get_list.php';
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.php';
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
const API_REACT = 'modules/turnero/api/chat_react.php';
const API_PLANTILLAS = 'modules/turnero/api/chat_get_plantillas.php';
const API_BUSCAR_PAC = 'modules/turnero/api/chat_buscar_paciente.php';
const API_START_CONV = 'modules/turnero/api/chat_start_conversation.php';
let _tplSelected = null;
const BASE = (function() {
const s = window.location.pathname;
return s.replace(/\/erp\.php.*$/, '/') || '/';
})();
let state = {
contacts: [],
activeUserId: null,
activeUser: null,
messages: [],
earliest: null,
earliestId: null,
hasMore: false,
polling: null,
lastPollTime: null,
};
// ── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadContacts();
document.getElementById('searchInput').addEventListener('input', debounce(() => {
loadContacts(document.getElementById('searchInput').value.trim());
}, 300));
setInterval(pollMessages, 4000);
setInterval(() => loadContacts(document.getElementById('searchInput').value.trim(), true), 12000);
});
function debounce(fn, ms) {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
// ── Contactos ─────────────────────────────────────────────────────────────────
async function loadContacts(search = '', silent = false) {
const url = BASE + API_LIST + '?limit=100' + (search ? '&search=' + encodeURIComponent(search) : '');
try {
const res = await fetch(url);
const json = await res.json();
if (!json.success) return;
state.contacts = json.data;
renderContacts(json.data);
} catch(e) {
if (!silent) console.error('loadContacts:', e);
}
}
function renderContacts(contacts) {
const el = document.getElementById('contactList');
if (!contacts.length) {
el.innerHTML = '<div class="contact-empty"><i class="fas fa-comment-slash"></i><br>Sin conversaciones aún</div>';
return;
}
el.innerHTML = contacts.map(c => {
const initials = (c.name || c.phone_number).charAt(0).toUpperCase();
const preview = escHtml(c.last_message || '—');
const time = c.last_time ? formatTime(c.last_time) : '';
const unread = c.unread_count > 0 ? `<span class="badge-unread">${c.unread_count}</span>` : '';
const active = state.activeUserId === c.user_id ? ' active' : '';
return `<div class="contact-item${active}" onclick="openChat(${c.user_id})">
<div class="contact-avatar">${initials}</div>
<div class="contact-info">
<div class="contact-name">${escHtml(c.name || c.phone_number)}</div>
<div class="contact-last">${preview}</div>
</div>
<div class="contact-meta">
<span class="contact-time">${time}</span>
${unread}
</div>
</div>`;
}).join('');
}
// ── Abrir chat ────────────────────────────────────────────────────────────────
async function openChat(userId) {
state.activeUserId = userId;
state.messages = [];
state.earliest = null;
state.earliestId = null;
state.hasMore = false;
state.lastPollTime = null;
const contact = state.contacts.find(c => c.user_id === userId);
state.activeUser = contact;
document.getElementById('emptyState').style.display = 'none';
const activeEl = document.getElementById('activeChat');
activeEl.style.display = 'flex';
activeEl.style.flexDirection = 'column';
activeEl.style.overflow = 'hidden';
activeEl.style.flex = '1';
const initials = contact ? (contact.name || contact.phone_number).charAt(0).toUpperCase() : '?';
document.getElementById('topbarAvatar').textContent = initials;
document.getElementById('topbarName').textContent = contact ? (contact.name || contact.phone_number) : '…';
document.getElementById('topbarPhone').textContent = contact ? contact.phone_number : '';
document.querySelectorAll('.contact-item').forEach(el => el.classList.remove('active'));
const items = document.querySelectorAll('.contact-item');
items.forEach(el => { if (el.onclick.toString().includes(`openChat(${userId})`)) el.classList.add('active'); });
const area = document.getElementById('messagesArea');
area.innerHTML = '<div style="text-align:center;padding:20px;color:#aaa"><i class="fas fa-spinner fa-spin"></i></div>';
await fetchMessages();
markRead(userId);
loadContacts(document.getElementById('searchInput').value.trim(), true);
openChatMobile();
}
function isMobile() { return window.innerWidth <= 680; }
function openChatMobile() {
if (!isMobile()) return;
document.getElementById('sidebar').classList.add('mobile-hidden');
document.getElementById('chatWindow').classList.add('mobile-active');
}
function closeChatMobile() {
if (!isMobile()) return;
document.getElementById('sidebar').classList.remove('mobile-hidden');
document.getElementById('chatWindow').classList.remove('mobile-active');
}
// ── Mensajes ──────────────────────────────────────────────────────────────────
async function fetchMessages() {
if (!state.activeUserId) return;
const forUser = state.activeUserId; // captura antes del await
const url = BASE + API_MESSAGES + '?user_id=' + forUser + '&limit=50';
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success) return;
state.messages = json.data;
state.hasMore = json.has_more;
state.earliest = json.earliest;
state.earliestId = json.earliest_id;
state.lastPollTime = json.data.length ? json.data[json.data.length - 1].created_at : null;
renderMessages(json.data, false);
} catch(e) { console.error('fetchMessages:', e); }
}
async function loadMoreMessages() {
if (!state.activeUserId || !state.earliest) return;
const forUser = state.activeUserId;
const url = BASE + API_MESSAGES
+ '?user_id=' + forUser
+ '&limit=50'
+ '&before=' + encodeURIComponent(state.earliest)
+ (state.earliestId ? '&before_id=' + state.earliestId : '');
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return;
if (!json.success || !json.data.length) {
document.getElementById('loadMoreBtn').style.display = 'none';
return;
}
state.messages = [...json.data, ...state.messages];
state.hasMore = json.has_more;
state.earliest = json.earliest;
state.earliestId = json.earliest_id;
prependMessages(json.data);
} catch(e) { console.error('loadMoreMessages:', e); }
}
async function pollMessages() {
if (!state.activeUserId || !state.lastPollTime) return;
const forUser = state.activeUserId;
const sinceTime = state.lastPollTime;
const url = BASE + API_MESSAGES
+ '?user_id=' + forUser
+ '&limit=50'
+ '&since=' + encodeURIComponent(sinceTime);
try {
const res = await fetch(url);
const json = await res.json();
if (forUser !== state.activeUserId) return; // usuario cambió mientras esperaba
if (!json.success || !json.data.length) return;
json.data.forEach(m => {
if (!state.messages.find(x => x.id === m.id)) {
state.messages.push(m);
appendMessage(m);
}
});
state.lastPollTime = json.data[json.data.length - 1].created_at;
markRead(forUser);
loadContacts(document.getElementById('searchInput').value.trim(), true);
} catch(e) {}
}
// ── Render mensajes ───────────────────────────────────────────────────────────
let _lastRenderedDay = null;
function renderMessages(msgs, prepend) {
const area = document.getElementById('messagesArea');
area.innerHTML = '';
const loadBtn = document.createElement('button');
loadBtn.className = 'load-more-btn';
loadBtn.id = 'loadMoreBtn';
loadBtn.style.display = state.hasMore ? 'block' : 'none';
loadBtn.onclick = loadMoreMessages;
loadBtn.innerHTML = '<i class="fas fa-chevron-up"></i> Ver mensajes anteriores';
area.appendChild(loadBtn);
let lastDay = null;
msgs.forEach(m => {
const day = m.created_at ? msgDay(m.created_at) : null;
if (day && day !== lastDay) {
area.appendChild(buildDateSep(dateLabel(m.created_at)));
lastDay = day;
}
area.appendChild(buildBubble(m));
});
_lastRenderedDay = lastDay;
area.scrollTop = area.scrollHeight;
}
function prependMessages(msgs) {
const area = document.getElementById('messagesArea');
const oldTop = area.scrollHeight - area.scrollTop;
const loadBtn = document.getElementById('loadMoreBtn');
// Día del primer mensaje que ya estaba en pantalla (para no duplicar separador)
const firstBubble = loadBtn.nextSibling;
const firstExistingDay = firstBubble?.dataset?.msgId
? msgDay(msgs[msgs.length - 1]?.created_at || '') // comparar con el último del lote nuevo
: null;
let lastDay = null;
let insertRef = loadBtn.nextSibling;
msgs.forEach(m => {
const day = m.created_at ? msgDay(m.created_at) : null;
if (day && day !== lastDay) {
area.insertBefore(buildDateSep(dateLabel(m.created_at)), insertRef);
lastDay = day;
}
area.insertBefore(buildBubble(m), insertRef);
insertRef = area.querySelector(`[data-msg-id="${m.id}"]`)?.nextSibling || insertRef;
});
// Eliminar separador duplicado si el último día del lote coincide con el primero ya visible
const seps = area.querySelectorAll('.date-sep');
for (let i = 0; i < seps.length - 1; i++) {
if (seps[i].textContent === seps[i+1].textContent) seps[i+1].remove();
}
if (!state.hasMore) loadBtn.style.display = 'none';
area.scrollTop = area.scrollHeight - oldTop;
}
function appendMessage(msg) {
const area = document.getElementById('messagesArea');
const day = msg.created_at ? msgDay(msg.created_at) : null;
if (day && day !== _lastRenderedDay) {
area.appendChild(buildDateSep(dateLabel(msg.created_at)));
_lastRenderedDay = day;
}
area.appendChild(buildBubble(msg));
area.scrollTop = area.scrollHeight;
}
function buildBubble(msg) {
const div = document.createElement('div');
div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming');
div.dataset.msgId = msg.id;
div.dataset.waId = msg.message_id || '';
const _base = window.location.origin;
const _toAbs = p => p ? (p.startsWith('http') ? p : `${_base}/${p.replace(/^\//, '')}`) : null;
const mediaSrc = msg.local_file ? _toAbs(msg.local_file) : (msg.media_url_external ? _toAbs(msg.media_url_external) : null);
let content = '';
if (msg.message_type === 'image' && mediaSrc) {
content = `<img class="msg-img" src="${escHtml(mediaSrc)}" loading="lazy"
onclick="window.open('${escHtml(mediaSrc)}','_blank')" alt="imagen">`;
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (msg.message_type === 'video' && mediaSrc) {
content = `<video class="msg-video" src="${escHtml(mediaSrc)}" controls preload="metadata"></video>`;
if (msg.content) content += `<div style="font-size:.82rem;margin-top:2px">${escHtml(msg.content)}</div>`;
} else if (msg.message_type === 'audio') {
if (mediaSrc) {
content = `<audio class="msg-audio" controls preload="metadata"
onerror="this.outerHTML='<div style=\\'font-size:.78rem;color:#e53e3e;padding:4px 0\\'><i class=\\'fas fa-exclamation-circle\\'></i> Audio no disponible</div>'">
<source src="${escHtml(mediaSrc)}" type="audio/ogg">
<source src="${escHtml(mediaSrc)}" type="audio/mpeg">
<source src="${escHtml(mediaSrc)}" type="audio/wav">
<source src="${escHtml(mediaSrc)}" type="audio/webm">
</audio>`;
} else {
content = `<div style="font-size:.78rem;color:#aaa;padding:4px 0"><i class="fas fa-microphone-slash"></i> Audio pendiente de descarga</div>`;
}
} else if (msg.message_type === 'document' && mediaSrc) {
const icon = (msg.mime_type || '').includes('pdf') ? 'fa-file-pdf' :
(msg.mime_type || '').includes('spreadsheet') || (msg.mime_type || '').includes('excel') ? 'fa-file-excel' :
(msg.mime_type || '').includes('word') ? 'fa-file-word' : 'fa-file-alt';
content = `<a class="msg-doc-link" href="${escHtml(mediaSrc)}" target="_blank">
<i class="fas ${icon}" style="font-size:1.3rem"></i>
<span>${escHtml(msg.filename || 'Documento')}</span>
</a>`;
} else {
content = nl2br(escHtml(msg.content || ''));
}
const time = msg.created_at ? formatTimeFull(msg.created_at) : '';
const statusIcon = msg.direction === 'outgoing'
? (msg.status === 'read' ? ' ✓✓' : msg.status === 'delivered' ? ' ✓✓' : ' ✓')
: '';
const reactBtn = msg.message_id
? `<button class="react-btn" onclick="showReactionPicker(event, '${escHtml(msg.message_id)}')">😊</button>`
: '';
div.innerHTML = content
+ `<div class="msg-time">${time}<span style="opacity:.7;font-size:.7rem">${statusIcon}</span></div>`
+ (msg.reaction_emoji ? `<div class="msg-reaction">${escHtml(msg.reaction_emoji)}</div>` : '')
+ reactBtn;
return div;
}
// ── Enviar texto ─────────────────────────────────────────────────────────────
async function sendMessage() {
// Si hay archivo adjunto, enviar como media
if (attachFile) { await sendMedia(); return; }
const input = document.getElementById('msgInput');
const message = input.value.trim();
if (!message || !state.activeUserId) return;
const btn = document.getElementById('btnSend');
btn.disabled = true;
input.value = '';
autoResize(input);
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, message })
});
const json = await res.json();
if (!json.success) {
alert('Error al enviar: ' + (json.error || 'desconocido'));
input.value = message;
} else {
await pollMessages();
}
} catch(e) {
alert('Error de red al enviar el mensaje');
input.value = message;
} finally {
btn.disabled = false;
input.focus();
}
}
// ── Adjuntar archivo ──────────────────────────────────────────────────────────
let attachFile = null;
function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
attachFile = file;
const prev = document.getElementById('attach-preview');
const thumb = document.getElementById('attachThumb');
const name = document.getElementById('attachName');
name.textContent = file.name + ' (' + (file.size > 1048576 ? (file.size/1048576).toFixed(1) + ' MB' : Math.round(file.size/1024) + ' KB') + ')';
if (file.type.startsWith('image/')) {
thumb.src = URL.createObjectURL(file);
thumb.style.display = 'block';
} else {
thumb.style.display = 'none';
}
prev.style.display = 'flex';
e.target.value = '';
}
function clearAttach() {
attachFile = null;
document.getElementById('attach-preview').style.display = 'none';
document.getElementById('attachThumb').src = '';
}
async function sendMedia() {
if (!attachFile || !state.activeUserId) return;
const btn = document.getElementById('btnSend');
btn.disabled = true;
const caption = document.getElementById('msgInput').value.trim();
const fd = new FormData();
fd.append('user_id', state.activeUserId);
fd.append('file', attachFile);
if (caption) fd.append('caption', caption);
try {
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
const json = await res.json();
if (!json.success) {
alert('Error al enviar archivo: ' + (json.error || 'desconocido'));
} else {
clearAttach();
document.getElementById('msgInput').value = '';
autoResize(document.getElementById('msgInput'));
await pollMessages();
}
} catch(e) {
alert('Error de red al enviar el archivo');
} finally {
btn.disabled = false;
}
}
// ── Emoji picker ──────────────────────────────────────────────────────────────
const EMOJI_LIST = ['😀','😂','😍','😎','😢','😡','🤔','👍','👎','❤️','🔥','✅','⭐','🎉','🙏','💪','🤣','😅','😊','🥰','😘','🤗','😴','🤒','😷','💊','🏥','👨‍⚕️','👩‍⚕️','📋','📞','📅','⏰','💉','🩺'];
function toggleEmojiPanel() {
const panel = document.getElementById('emojiPanel');
if (!panel.classList.contains('show')) {
panel.innerHTML = EMOJI_LIST.map(e => `<span onclick="insertEmoji('${e}')">${e}</span>`).join('');
}
panel.classList.toggle('show');
}
function insertEmoji(em) {
const inp = document.getElementById('msgInput');
const pos = inp.selectionStart;
inp.value = inp.value.slice(0, pos) + em + inp.value.slice(pos);
inp.selectionStart = inp.selectionEnd = pos + em.length;
inp.focus();
autoResize(inp);
document.getElementById('emojiPanel').classList.remove('show');
}
document.addEventListener('click', e => {
const panel = document.getElementById('emojiPanel');
if (panel.classList.contains('show') && !e.target.closest('.chat-input-area')) {
panel.classList.remove('show');
}
const rp = document.getElementById('reaction-picker');
if (rp.classList.contains('show') && !e.target.closest('#reaction-picker') && !e.target.closest('.react-btn')) {
rp.classList.remove('show');
}
});
// ── Reacciones ────────────────────────────────────────────────────────────────
let _reactionTarget = null;
function showReactionPicker(e, waId) {
e.stopPropagation();
_reactionTarget = waId;
const rp = document.getElementById('reaction-picker');
rp.classList.add('show');
const rect = e.target.getBoundingClientRect();
rp.style.top = (rect.top - 60) + 'px';
rp.style.left = Math.max(4, rect.left - 80) + 'px';
}
async function sendReactionEmoji(emoji) {
document.getElementById('reaction-picker').classList.remove('show');
if (!_reactionTarget || !state.activeUserId) return;
const waId = _reactionTarget;
try {
const res = await fetch(BASE + API_REACT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, message_id: waId, emoji })
});
const json = await res.json();
if (json.success) {
// Actualizar burbuja en el DOM inmediatamente
const bubble = document.querySelector(`[data-wa-id="${waId}"]`);
if (bubble) {
let reEl = bubble.querySelector('.msg-reaction');
if (!reEl) {
reEl = document.createElement('div');
reEl.className = 'msg-reaction';
bubble.appendChild(reEl);
}
reEl.textContent = emoji;
}
// Actualizar estado en memoria para consistencia
const m = state.messages.find(x => x.message_id === waId);
if (m) m.reaction_emoji = emoji;
}
} catch(e) { console.error('reaction:', e); }
}
// ── Grabación de audio ────────────────────────────────────────────────────────
let mediaRecorder = null, audioChunks = [], recInterval = null, recSeconds = 0;
async function toggleRecording() {
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioChunks = [];
mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/ogg' });
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.start(100);
recSeconds = 0;
document.getElementById('recording-bar').style.display = 'flex';
document.getElementById('recTimer').textContent = '0:00';
document.getElementById('micBtn').style.color = '#e53e3e';
recInterval = setInterval(() => {
recSeconds++;
const m = Math.floor(recSeconds/60), s = recSeconds%60;
document.getElementById('recTimer').textContent = m + ':' + String(s).padStart(2,'0');
}, 1000);
} catch(e) {
alert('No se pudo acceder al micrófono: ' + e.message);
}
}
async function stopRecording() {
if (!mediaRecorder) return;
clearInterval(recInterval);
document.getElementById('recording-bar').style.display = 'none';
document.getElementById('micBtn').style.color = '';
return new Promise(resolve => {
mediaRecorder.onstop = async () => {
const mime = mediaRecorder.mimeType || 'audio/webm';
const blob = new Blob(audioChunks, { type: mime });
const ext = mime.includes('ogg') ? 'ogg' : 'webm';
const file = new File([blob], `audio_${Date.now()}.${ext}`, { type: mime });
mediaRecorder.stream.getTracks().forEach(t => t.stop());
mediaRecorder = null;
if (!state.activeUserId) { resolve(); return; }
const fd = new FormData();
fd.append('user_id', state.activeUserId);
fd.append('file', file);
fd.append('is_voice', '1');
try {
const res = await fetch(BASE + API_MEDIA, { method: 'POST', body: fd });
const json = await res.json();
if (json.success) await pollMessages();
else alert('Error al enviar audio: ' + (json.error || ''));
} catch(e) { alert('Error de red al enviar audio'); }
resolve();
};
mediaRecorder.stop();
});
}
function cancelRecording() {
if (!mediaRecorder) return;
clearInterval(recInterval);
mediaRecorder.stream.getTracks().forEach(t => t.stop());
mediaRecorder = null;
audioChunks = [];
document.getElementById('recording-bar').style.display = 'none';
document.getElementById('micBtn').style.color = '';
}
// ── Nuevo mensaje ────────────────────────────────────────────────────────────
const nuevoMensaje = (() => {
let _phone = '';
let _destLabel = '';
let _patientName = '';
let _tpl = null;
let _buscarTimer = null;
function _show(stepId) {
['nv-step-dest','nv-step-tpl','nv-step-vars'].forEach(id => {
document.getElementById(id).style.display = id === stepId ? '' : 'none';
});
}
function abrir() {
_phone = ''; _destLabel = ''; _patientName = ''; _tpl = null;
document.getElementById('nv-pac-search').value = '';
document.getElementById('nv-phone-input').value = '';
document.getElementById('nv-phone-hint').textContent = '';
document.getElementById('nv-pac-results').innerHTML = '';
document.getElementById('nv-btn-siguiente').disabled = true;
_show('nv-step-dest');
document.getElementById('nuevo-modal').classList.add('show');
document.getElementById('nv-pac-search').focus();
}
function cerrar() {
document.getElementById('nuevo-modal').classList.remove('show');
}
function _setDest(phone, label) {
_phone = phone; _destLabel = label;
document.getElementById('nv-btn-siguiente').disabled = !phone;
}
function onPhoneInput(val) {
const digits = val.replace(/\D/,'');
const hint = document.getElementById('nv-phone-hint');
if (!digits) { _setDest('', ''); hint.textContent = ''; return; }
if (digits.length >= 10) {
hint.textContent = '✅ Número válido';
hint.style.color = '#16a34a';
_setDest(digits, '+' + (digits.length === 10 ? '57' : '') + digits);
} else {
hint.textContent = digits.length + ' dígitos — mínimo 10';
hint.style.color = '#94a3b8';
_setDest('', '');
}
// Clear patient selection if typing a phone
_patientName = '';
document.getElementById('nv-pac-search').value = '';
document.getElementById('nv-pac-results').innerHTML = '';
}
function buscarPaciente(q) {
document.getElementById('nv-phone-input').value = '';
document.getElementById('nv-phone-hint').textContent = '';
_setDest('', '');
clearTimeout(_buscarTimer);
const el = document.getElementById('nv-pac-results');
if (q.length < 2) { el.innerHTML = ''; return; }
el.innerHTML = '<div style="padding:8px;font-size:.8rem;color:#94a3b8"><i class="fas fa-spinner fa-spin"></i> Buscando…</div>';
_buscarTimer = setTimeout(async () => {
try {
const res = await fetch(BASE + API_BUSCAR_PAC + '?q=' + encodeURIComponent(q));
const json = await res.json();
if (!json.ok || !json.data?.length) {
el.innerHTML = '<div style="padding:8px;font-size:.8rem;color:#94a3b8">Sin resultados</div>';
return;
}
el.innerHTML = '';
json.data.forEach(p => {
const div = document.createElement('div');
div.className = 'nv-pac-item';
const tel = p.telefono || '—';
div.innerHTML =
`<span class="nv-pac-name">${escHtml(p.nombre_completo)}</span>` +
`<span class="nv-pac-sub">${escHtml(p.numero_documento || '')} · 📱 ${escHtml(tel)}</span>`;
div.onclick = () => {
const phone = (p.telefono || '').replace(/\D/g,'');
if (!phone) { alert('Este paciente no tiene teléfono registrado.'); return; }
_patientName = p.nombre_completo;
_setDest(phone, p.nombre_completo + ' · ' + tel);
document.getElementById('nv-pac-search').value = p.nombre_completo;
el.innerHTML = '';
};
el.appendChild(div);
});
} catch(e) {
el.innerHTML = '<div style="padding:8px;font-size:.8rem;color:#ef4444">Error al buscar</div>';
}
}, 350);
}
async function irAPlantilla() {
if (!_phone) return;
document.getElementById('nv-dest-label').textContent = _destLabel || _phone;
_show('nv-step-tpl');
const listEl = document.getElementById('nv-tpl-list');
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:#94a3b8;font-size:.84rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
try {
const res = await fetch(BASE + API_PLANTILLAS);
const json = await res.json();
if (!json.ok || !json.data?.length) {
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:#94a3b8;font-size:.84rem">No hay plantillas habilitadas.<br><small>Configúralas en Configuración → Sesión.</small></div>';
return;
}
listEl.innerHTML = '';
json.data.forEach(tpl => {
const div = document.createElement('div');
div.className = 'tpl-item';
div.innerHTML =
`<span class="tpl-item-name">${escHtml(tpl.name)}</span>` +
`<span class="tpl-item-meta">${escHtml(tpl.template_name)} · ${escHtml(tpl.language_code)}</span>` +
(tpl.body_text ? `<span class="tpl-item-body">${escHtml(tpl.body_text.substring(0,80))}${tpl.body_text.length>80?'…':''}</span>` : '');
div.onclick = () => irAVariables(tpl);
listEl.appendChild(div);
});
} catch(e) {
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:#ef4444;font-size:.84rem">Error al cargar plantillas</div>';
}
}
async function irAVariables(tpl) {
_tpl = tpl;
document.getElementById('nv-vars-title').textContent = tpl.name;
const previewBox = document.getElementById('nv-preview-box');
if (tpl.body_text) { previewBox.textContent = tpl.body_text; previewBox.style.display = ''; }
else previewBox.style.display = 'none';
const varsBody = document.getElementById('nv-vars-body');
varsBody.innerHTML = '<div style="padding:6px;color:#94a3b8;font-size:.82rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
_show('nv-step-vars');
try {
const res = await fetch(BASE + 'api/get_template_details.php?id=' + tpl.id);
const json = await res.json();
const vars = json.template?.variables ?? [];
if (!vars.length) {
varsBody.innerHTML = '<div style="padding:6px;font-size:.82rem;color:#64748b">Esta plantilla no requiere variables.</div>';
} else {
varsBody.innerHTML = '';
vars.forEach(v => {
const row = document.createElement('div');
row.className = 'tpl-var-row';
const ph = v.example || v.placeholder || v.label;
const varName = (v.placeholder || '').replace(/\{\{|\}\}/g, '').trim();
const autoVal = (varName === 'name' && _patientName) ? _patientName : '';
row.innerHTML = `<label>{{${v.index}}} — ${escHtml(v.label)}</label>` +
`<input type="text" data-var-index="${v.index}" data-var-placeholder="${escHtml(v.placeholder || '')}" placeholder="${escHtml(ph)}" value="${escHtml(autoVal)}">`;
varsBody.appendChild(row);
});
}
} catch(e) {
varsBody.innerHTML = '<div style="padding:6px;color:#ef4444;font-size:.82rem">Error al cargar variables</div>';
}
}
function volverADest() { _tpl = null; _show('nv-step-dest'); }
function volverAPlantilla() { _tpl = null; _show('nv-step-tpl'); }
async function enviar() {
if (!_phone || !_tpl) return;
const inputs = [...document.querySelectorAll('#nv-vars-body input[data-var-index]')];
const missing = inputs.findIndex(el => !el.value.trim());
if (missing !== -1) { inputs[missing].focus(); return; }
const params = _buildParams(inputs);
const templateName = _tpl.template_name;
const templateLang = _tpl.language_code;
cerrar();
try {
const res = await fetch(BASE + API_START_CONV, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: _phone, template_name: templateName, lang: templateLang, params })
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + (json.error || 'No se pudo enviar')); return; }
// Abrir la conversación recién creada
await loadContacts();
if (json.user_id) openChat(json.user_id);
} catch(e) { alert('Error de conexión al enviar el mensaje'); }
}
return { abrir, cerrar, buscarPaciente, onPhoneInput, irAPlantilla, volverADest, volverAPlantilla, enviar };
})();
// ── Plantilla (2 pasos) ───────────────────────────────────────────────────────
async function openTemplatePicker() {
if (!state.activeUserId) return;
_tplSelected = null;
document.getElementById('tpl-step-list').style.display = '';
document.getElementById('tpl-step-vars').style.display = 'none';
document.getElementById('tpl-modal').classList.add('show');
const listBody = document.getElementById('tpl-list-body');
listBody.innerHTML = '<div style="padding:20px;text-align:center;color:#94a3b8;font-size:.84rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
try {
const res = await fetch(BASE + API_PLANTILLAS);
const json = await res.json();
if (!json.ok || !json.data?.length) {
listBody.innerHTML = '<div style="padding:20px;text-align:center;color:#94a3b8;font-size:.84rem">No hay plantillas habilitadas.<br><small>Configúralas en Configuración → Sesión.</small></div>';
return;
}
listBody.innerHTML = '';
json.data.forEach(tpl => {
const div = document.createElement('div');
div.className = 'tpl-item';
div.innerHTML =
`<span class="tpl-item-name">${escHtml(tpl.name)}</span>` +
`<span class="tpl-item-meta">${escHtml(tpl.template_name)} · ${escHtml(tpl.language_code)}</span>` +
(tpl.body_text ? `<span class="tpl-item-body">${escHtml(tpl.body_text.substring(0, 80))}${tpl.body_text.length > 80 ? '…' : ''}</span>` : '');
div.onclick = () => selectTemplate(tpl);
listBody.appendChild(div);
});
} catch(e) {
listBody.innerHTML = '<div style="padding:20px;text-align:center;color:#ef4444;font-size:.84rem">Error al cargar plantillas.</div>';
}
}
function closeTemplatePicker() {
document.getElementById('tpl-modal').classList.remove('show');
_tplSelected = null;
}
function tplGoBack() {
document.getElementById('tpl-step-list').style.display = '';
document.getElementById('tpl-step-vars').style.display = 'none';
_tplSelected = null;
}
async function selectTemplate(tpl) {
_tplSelected = tpl;
document.getElementById('tpl-step-list').style.display = 'none';
document.getElementById('tpl-step-vars').style.display = '';
document.getElementById('tpl-vars-title').textContent = tpl.name;
const previewBox = document.getElementById('tpl-preview-box');
if (tpl.body_text) {
previewBox.textContent = tpl.body_text;
previewBox.style.display = '';
} else {
previewBox.style.display = 'none';
}
const varsBody = document.getElementById('tpl-vars-body');
varsBody.innerHTML = '<div style="padding:8px 16px;color:#94a3b8;font-size:.82rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
try {
const res = await fetch(BASE + 'api/get_template_details.php?id=' + tpl.id);
const json = await res.json();
const vars = json.template?.variables ?? [];
if (!vars.length) {
varsBody.innerHTML = '<div style="padding:8px 16px;font-size:.82rem;color:#64748b">Esta plantilla no requiere variables.</div>';
} else {
varsBody.innerHTML = '';
vars.forEach(v => {
const row = document.createElement('div');
row.className = 'tpl-var-row';
const label = v.label || ('Variable ' + v.index);
const ph = v.example || v.placeholder || label;
row.innerHTML = `<label>{{${v.index}}} — ${escHtml(label)}</label>` +
`<input type="text" data-var-index="${v.index}" data-var-placeholder="${escHtml(v.placeholder || '')}" placeholder="${escHtml(ph)}">`;
varsBody.appendChild(row);
});
}
} catch(e) {
varsBody.innerHTML = '<div style="padding:8px 16px;color:#ef4444;font-size:.82rem">Error al cargar variables.</div>';
}
}
function _buildParams(inputs) {
const hasNamed = inputs.some(el => {
const n = (el.dataset.varPlaceholder || '').replace(/\{\{|\}\}/g, '').trim();
return n && !/^\d+$/.test(n);
});
if (hasNamed) {
const obj = {};
inputs.forEach(el => {
const n = (el.dataset.varPlaceholder || '').replace(/\{\{|\}\}/g, '').trim() || el.dataset.varIndex;
obj[n] = el.value.trim();
});
return obj;
}
return inputs.map(el => el.value.trim());
}
async function sendTemplate() {
if (!_tplSelected || !state.activeUserId) return;
const inputs = [...document.querySelectorAll('#tpl-vars-body input[data-var-index]')];
const missing = inputs.findIndex(el => !el.value.trim());
if (missing !== -1) { inputs[missing].focus(); return; }
const params = _buildParams(inputs);
const templateName = _tplSelected.template_name;
const templateLang = _tplSelected.language_code;
closeTemplatePicker();
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: state.activeUserId,
type: 'template',
template: templateName,
lang: templateLang,
params
})
});
const json = await res.json();
if (!json.success) alert('Error al enviar plantilla: ' + (json.error || ''));
else await pollMessages();
} catch(e) { alert('Error de red al enviar plantilla'); }
}
async function markRead(userId) {
try {
await fetch(BASE + API_READ, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
} catch(e) {}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function nl2br(str) { return str.replace(/\n/g, '<br>'); }
function dateLabel(ts) {
const d = new Date(ts.replace(' ', 'T'));
const now = new Date();
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === now.toDateString()) return 'Hoy';
if (d.toDateString() === yesterday.toDateString()) return 'Ayer';
return d.toLocaleDateString('es-CO', { weekday:'short', day:'2-digit', month:'short' });
}
function buildDateSep(label) {
const el = document.createElement('div');
el.className = 'date-sep';
el.textContent = label;
return el;
}
function msgDay(ts) { return new Date(ts.replace(' ', 'T')).toDateString(); }
function formatTime(ts) {
const d = new Date(ts.replace(' ', 'T'));
const now = new Date();
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString('es-CO', { day: '2-digit', month: '2-digit' });
}
function formatTimeFull(ts) {
const d = new Date(ts.replace(' ', 'T'));
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
}
</script>
</body>
</html>