Files
whatsapp/conversations.php
T
2026-05-27 20:40:17 -05:00

8123 lines
437 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
session_start();
require_once 'config/config.php';
// Verificar autenticación igual que el resto del sistema
if (!isUserLoggedIn()) {
header('Location: login.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>💬 Conversaciones - WhatsApp Bot</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--whatsapp-green: #25d366;
--whatsapp-green-dark: #128c7e;
--whatsapp-light: #dcf8c6;
--whatsapp-dark: #075e54;
--message-bg: #f0f0f0;
--sidebar-bg: #f8f9fa;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #f0f4f7 0%, #e5ddd5 100%);
margin: 0;
min-height: 100vh;
overflow: hidden;
color: #222;
font-size: 14px;
}
.chat-container {
display: flex;
height: 100vh;
background: transparent;
gap: 12px;
padding: 12px;
}
.chat-sidebar {
width: 360px;
background: var(--sidebar-bg);
border-radius: 8px;
border: 1px solid #e9eef2;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(16,24,40,0.04);
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px;
background: linear-gradient(180deg, var(--whatsapp-dark) 0%, #054f45 100%);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-search {
padding: 10px 12px;
background: white;
border-bottom: 1px solid #eef2f5;
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 6px;
scroll-behavior: smooth;
}
/* Scrollbar personalizado estilo WhatsApp (fino y discreto) */
.conversation-list::-webkit-scrollbar { width: 4px; }
.conversation-list::-webkit-scrollbar-track { background: transparent; }
.conversation-list::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 4px; }
.conversation-list::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.28); }
/* Buscador mejorado */
.search-wrapper { position: relative; }
.search-spinner { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none; color: #999; font-size: 13px; }
.search-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none; background: none; border: none; padding: 0; color: #999; cursor: pointer; font-size: 14px; line-height: 1; }
.search-clear:hover { color: #333; }
.conversation-item {
padding: 12px 14px;
border-bottom: 1px solid #f1f5f8;
cursor: pointer;
transition: background-color 0.12s, transform 0.08s;
display: flex;
align-items: center;
}
.conversation-item:hover {
background: #f6fbff;
transform: translateY(-1px);
}
.conversation-item.active {
background: #e9fbf1;
border-right: 3px solid var(--whatsapp-green);
}
.conversation-avatar {
width: 46px;
height: 46px;
border-radius: 50%;
background: var(--whatsapp-green);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
margin-right: 12px;
flex-shrink: 0;
}
.conversation-info {
flex: 1;
min-width: 0;
}
.conversation-name {
font-weight: 700;
font-size: 15px;
color: #262626;
margin-bottom: 4px;
}
.conversation-preview {
color: #6b7280;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-time {
color: #9aa4af;
font-size: 12px;
text-align: right;
flex-shrink: 0;
margin-left: 10px;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: transparent;
}
.chat-header {
padding: 12px 18px;
background: linear-gradient(180deg, var(--whatsapp-dark) 0%, #0a5347 100%);
color: white;
border-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 6px rgba(15,23,42,0.06);
}
.chat-header-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--whatsapp-green);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
margin-right: 12px;
}
.chat-header-info h6 { margin: 0; font-size: 15px; }
.chat-header-info small { color: rgba(255,255,255,0.85); }
.chat-conversations {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
background: #f8f9fb;
border: 1px solid #eef2f5;
border-radius: 0 0 8px 8px;
}
.message {
width: 100%;
margin-bottom: 8px;
display: flex;
align-items: flex-end;
}
.message.outgoing {
justify-content: flex-end;
}
.message.incoming {
justify-content: flex-start;
}
.message-bubble {
padding: 8px 12px;
border-radius: 18px;
position: relative;
word-wrap: break-word;
box-shadow: 0 3px 10px rgba(2,6,23,0.06);
max-width: 72%;
line-height: 1.35;
transition: transform 0.08s ease, box-shadow 0.12s ease;
}
.message.outgoing .message-bubble {
background: var(--whatsapp-light);
color: #222;
border-bottom-right-radius: 6px;
}
.message.incoming .message-bubble {
background: white;
color: #222;
border-bottom-left-radius: 6px;
}
/* Cola tipo WhatsApp */
.message.outgoing .message-bubble::after {
content: '';
position: absolute;
right: -6px;
bottom: 6px;
width: 12px;
height: 12px;
background: var(--whatsapp-light);
transform: rotate(45deg);
z-index: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.message.incoming .message-bubble::after {
content: '';
position: absolute;
left: -6px;
bottom: 6px;
width: 12px;
height: 12px;
background: white;
transform: rotate(45deg);
z-index: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
/* Mensajes no leídos nuevos */
.message.unread .message-bubble {
background: #fffbea;
border: 1px solid rgba(255, 193, 7, 0.3);
animation: highlightNew 0.6s ease;
}
.message.unread.incoming .message-bubble::after {
background: #fffbea;
}
@keyframes highlightNew {
0% { background: #fff9c4; transform: scale(1.02); }
100% { background: #fffbea; transform: scale(1); }
}
/* Timestamp badge shown at the corner of each bubble (WhatsApp-like) */
.message-bubble { padding-bottom: 20px; }
.message-bubble .message-time {
position: absolute;
right: 8px;
bottom: 6px;
font-size: 11px;
line-height: 1;
color: #666;
opacity: 1; /* always visible */
transition: opacity 0.12s ease-in-out, transform 0.12s ease;
white-space: nowrap;
font-weight: 500;
background: transparent;
padding: 0 4px;
}
/* Different color for outgoing (light text over green bubble) */
.message.outgoing .message-bubble .message-time { color: rgba(255,255,255,0.92); }
.message.incoming .message-bubble .message-time { color: rgba(32,37,41,0.7); }
/* reduce prominence on very small screens */
@media (max-width:420px) {
.message-bubble .message-time { font-size:10px; right:6px; bottom:4px; }
}
/* Preserve user whitespace and line breaks like WhatsApp */
.message-content {
white-space: pre-wrap; /* preserve newlines and wrap long lines */
white-space: -moz-pre-wrap;
white-space: -pre-wrap;
white-space: -o-pre-wrap;
word-break: break-word;
}
/* WhatsApp-like inline formatting and interactive messages */
.wa-interactive { margin-top: 8px; border: 1px solid rgba(0,0,0,0.06); padding: 8px; border-radius: 8px; background: #fff; }
.wa-interactive .wa-title { font-weight:700; margin-bottom:6px; }
.wa-interactive .wa-buttons { display:flex; gap:6px; flex-wrap:wrap; }
.wa-interactive .wa-buttons .wa-interactive-btn { white-space:nowrap; }
.wa-interactive .wa-list { margin-top:6px; }
.wa-interactive .wa-list-item { padding:8px; border-radius:6px; cursor:pointer; border:1px solid transparent; }
.wa-interactive .wa-list-item:hover { background:#f6f8fb; border-color:rgba(0,0,0,0.04); }
.wa-code { background:#0b0b0b; color:#fff; padding:8px; border-radius:6px; font-family: monospace; white-space: pre-wrap; }
.wa-inline-code { background:#f4f4f4; padding:2px 6px; border-radius:4px; font-family: monospace; }
.message-bubble:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(2,6,23,0.06);
}
.message-bubble:hover .message-time,
.message-bubble:focus-within .message-time {
opacity: 1;
}
.message-status {
display: inline-block;
margin-left: 5px;
font-size: 12px;
}
.status-sent { color: #999; }
.status-delivered { color: #999; }
.status-read { color: var(--whatsapp-green); }
.chat-input {
padding: 15px 20px;
background: #f0f0f0;
border-top: 1px solid #ddd;
display: flex;
align-items: center;
gap: 10px;
}
.chat-input input[type="text"] {
flex: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 25px;
outline: none;
font-size: 14px;
}
.chat-input input:focus {
border-color: var(--whatsapp-green);
}
.chat-input .btn {
border-radius: 50%;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
background: var(--whatsapp-green);
border: none;
color: white;
}
.chat-input .btn:hover {
background: var(--whatsapp-green-dark);
}
#attach-btn {
background: #075E54;
}
#attach-btn:hover {
background: #128C7E;
}
/* Estilos para mensajes multimedia */
.message-media {
max-width: 300px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 5px;
}
.message-media img,
.message-media video {
width: 100%;
display: block;
cursor: pointer;
}
.message-media audio {
width: 100%;
}
.message-document {
background: rgba(0,0,0,0.05);
padding: 10px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.message-document:hover {
background: rgba(0,0,0,0.1);
}
.message-document i {
font-size: 24px;
color: var(--whatsapp-green);
}
.document-info {
flex: 1;
}
.document-name {
font-weight: 500;
font-size: 14px;
}
.document-size {
font-size: 12px;
color: #666;
}
#media-preview {
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.upload-progress {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--whatsapp-green);
transform-origin: left;
transition: transform 0.3s;
}
.no-conversation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
text-align: center;
}
.no-conversation i {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.3;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
/* Notification toasts (compact by default) */
#notification-toasts {
position: fixed;
top: 12px;
right: 12px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
pointer-events: none;
}
.notification-toast {
pointer-events: auto;
background: #fff;
color: #222;
padding: 8px 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
display: flex;
align-items: center;
gap: 10px;
min-width: 160px;
max-width: 260px;
font-size: 13px;
}
.notification-toast.small {
padding: 6px 8px;
font-size: 12px;
min-width: 140px;
max-width: 220px;
}
.notification-toast.cool {
background: linear-gradient(90deg,#6a11cb,#2575fc);
color: white;
box-shadow: 0 6px 16px rgba(37,117,252,0.28);
transform-origin: right;
animation: pop 320ms ease;
}
@keyframes pop { from { transform: translateY(-6px) scale(0.98); opacity:0 } to { transform: translateY(0) scale(1); opacity:1 } }
.notification-toast .nt-icon { font-size: 16px; margin-right: 6px; opacity: 0.95; }
.notification-toast .nt-actions { margin-left: auto; display:flex; gap:6px; }
.notification-toast .btn { font-size: 12px; padding: 4px 8px; }
/* UI improvements for chat area */
.quick-replies .btn {
border-radius: 20px;
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Quick replies compact panel */
.quick-replies-wrapper { position: relative; }
/* Improve audio control visibility and color in supporting browsers */
.message-media audio { background: #fff; border-radius: 8px; padding: 4px; accent-color: var(--whatsapp-green); }
/* Mic button styles */
#mic-btn {
margin-left: 6px;
background: var(--whatsapp-green);
border: none;
color: white;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(3, 102, 80, 0.12);
}
#mic-btn.recording { background: #c0392b; color: white; }
#mic-btn i { color: white; font-size: 14px; }
/* Attach '+' style and menu */
#attach-btn { background: transparent; border-radius: 6px; padding: 4px 10px; border: 1px solid transparent; font-weight:700; }
#attach-btn:hover { background: rgba(0,0,0,0.03); border-color: rgba(0,0,0,0.06); }
.attach-menu { background:#fff; border:1px solid #ddd; box-shadow:0 6px 18px rgba(0,0,0,0.08); border-radius:6px; padding:6px; display:none; position:absolute; z-index:1500; }
.attach-menu .attach-option { display:block; width:100%; text-align:left; padding:6px 10px; border:none; background:transparent; font-size:14px; }
.attach-menu .attach-option:hover { background:#f6f6f6; }
/* Show mic in place of send when input is empty */
#send-btn { display: inline-block; }
#mic-btn { display: none; }
.chat-conversations {
background-repeat: repeat;
background-color: #e9efe9;
padding-bottom: 20px; /* space for reply preview and input */
}
.message-bubble { transition: transform 0.12s ease, box-shadow 0.12s ease; }
.message-bubble:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0,0,0,0.06); }
#reply-preview { box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
#reply-preview button { border: none; color: #888; }
#reply-preview button:hover { color: #333; }
.notification-toast.urgent { border-left: 4px solid #e74c3c; }
/* Badge de usuario bloqueado en el header */
#blocked-indicator {
display: none;
margin-left: 8px;
background: #dc3545;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
vertical-align: middle;
letter-spacing: .3px;
}
/* Botón bloquear — estados */
#block-user-btn.is-blocked { background: rgba(220,53,69,.18); border-color: #dc3545; color: #dc3545; }
#block-user-btn.is-blocked:hover { background: rgba(220,53,69,.28); }
/* Ítem bloqueado en sidebar */
.conversation-item.user-blocked { opacity: .6; }
.conversation-item.user-blocked .conversation-name::after {
content: ' 🚫';
font-size: 10px;
}
/* Visual destacado para notificaciones de tipo "attention" (ej. usuario subió documentos) */
.notification-toast.attention {
background: linear-gradient(90deg, #fff7e6, #fff3e0);
border: 1px solid rgba(255,165,0,0.18);
color: #333;
box-shadow: 0 8px 24px rgba(255,165,0,0.06);
min-width: 240px;
}
.notification-toast.attention .nt-icon {
font-size: 18px;
margin-right: 8px;
transform: translateY(-1px);
color: #ff9900;
}
.notification-toast.attention .nt-actions .btn { min-width: 70px; }
.message-template {
background: #fffacd;
border: 1px solid #f0e68c;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
}
.message-template.system {
background: linear-gradient(90deg,#fff4e6,#fffaf0);
border: 1px solid rgba(255,165,0,0.18);
color: #5c3a00;
font-weight: 600;
display: flex;
gap: 8px;
align-items: center;
animation: systemPop 320ms ease;
}
.message-template.system .mt-icon { font-size:16px; margin-right:6px; }
@keyframes systemPop { from { transform: translateY(6px); opacity:0 } to { transform: translateY(0); opacity:1 } }
/* Highlight animation for newly inserted system messages */
.message-template.system.system-highlight {
box-shadow: 0 10px 30px rgba(255,165,0,0.12);
border-color: rgba(255,165,0,0.28);
transform-origin: center left;
animation: pulseHighlight 1s ease both;
}
/* Date separator between message days */
.date-sep {
text-align: center;
font-size: 12px;
color: #666;
padding: 6px 0;
margin: 12px 0;
position: relative;
}
.date-sep::before, .date-sep::after { content: ''; display: inline-block; vertical-align: middle; width: 20%; height: 1px; background: rgba(0,0,0,0.06); margin: 0 8px; }
.date-sep { background: transparent; font-weight:600; }
@keyframes pulseHighlight {
0% { transform: translateY(6px) scale(.995); opacity: 0.95; }
50% { transform: translateY(0) scale(1.01); opacity: 1; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
.unread-count {
background: var(--whatsapp-green);
color: white;
border-radius: 50%;
font-size: 12px;
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
/* Media thumbnails and players */
.message-media img, .message-media .message-media-image { max-width: 320px; border-radius: 8px; display: block; height: auto; }
@media (max-width: 768px) {
.message-media img, .message-media .message-media-image { max-width: 60vw; }
}
.message-media video, .message-media audio { max-width: 100%; border-radius: 8px; }
.message-media { margin-bottom: 6px; }
/* Quick replies: make them rectangular, full-width in panel, readable */
.quick-replies-panel {
background: #fff;
border-radius: 8px;
padding: 8px;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
display: none;
position: absolute;
z-index: 1400;
max-height: 180px;
overflow-y: auto;
}
.quick-replies-panel .btn {
border-radius: 6px;
display: block;
text-align: left;
width: 100%;
padding: 8px 10px;
margin-bottom: 6px;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
min-width: 72px;
max-width: 200px;
font-size: 13px;
}
#quick-replies-toggle { background: transparent; border-radius: 20px; }
@media (max-width: 768px) {
.quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; }
}
/* Indicador de nuevos mensajes */
#new-messages-indicator {
animation: bounceIn 0.4s ease, pulse 2s ease-in-out infinite;
}
@keyframes bounceIn {
0% { transform: translateX(-50%) translateY(20px); opacity: 0; }
60% { transform: translateX(-50%) translateY(-10px); opacity: 1; }
100% { transform: translateX(-50%) translateY(-6px); opacity: 1; }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 6px 20px rgba(37, 211, 102, 0.35); }
50% { box-shadow: 0 6px 30px rgba(37, 211, 102, 0.55); }
}
/* Recording controls: rectangular buttons and clearer contrast */
#recording-indicator .btn {
border-radius: 6px;
padding: 6px 10px;
min-width: 84px;
}
/* Audio control visibility: stronger border and accent color */
.message-media audio {
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
padding: 6px;
border-radius: 8px;
accent-color: var(--whatsapp-green);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
height: 40px;
}
@media (max-width: 768px) {
/* Fullscreen sidebar that can slide in/out */
.chat-container { padding: 0; gap: 0; }
.chat-sidebar {
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1100;
height: 100vh;
transform: translateX(0);
transition: transform 240ms ease;
box-shadow: none;
}
/* Hidden by default on mobile. .open will show it */
.chat-sidebar.mobile-hidden { transform: translateX(-110%); }
.chat-main {
width: 100%;
margin-left: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header adjustment */
.chat-header { padding: 10px 12px; gap: 8px; }
.chat-header-avatar { width: 38px; height: 38px; }
/* Fix input to bottom to emulate mobile chat apps */
.chat-input {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 10px calc(12px + env(safe-area-inset-left, 0px)) calc(12px + env(safe-area-inset-bottom, 0px));
background: #fff;
z-index: 1400;
border-top: 1px solid #e9eef2;
box-shadow: 0 -6px 20px rgba(0,0,0,0.06);
}
/* improve touch targets inside input bar */
.chat-input input[type="text"] { font-size: 16px; padding: 12px 14px; }
.chat-input .btn { width:48px; height:48px; }
/* Give chat area bottom padding so messages are not hidden under input */
.chat-conversations { padding-bottom: 120px; }
/* Compact avatars and message bubble width */
.conversation-avatar { width: 40px; height: 40px; }
.message-bubble { max-width: 85%; font-size: 15px; }
/* Quick replies panel adapt */
.quick-replies-panel { left: 8px; right: 8px; bottom: 70px; max-height: 160px; }
/* Show a small back button in header to open sidebar */
#sidebar-toggle { display: inline-flex; }
}
.reply-preview {
background: #f4f6f8;
border-left: 3px solid #d0d7de;
padding: 6px 8px;
margin-bottom: 6px;
font-size: 12px;
color: #555;
border-radius: 4px;
}
.reaction-badge { background:#fff; border:1px solid rgba(0,0,0,0.06); display:inline-flex; align-items:center; justify-content:center; padding:4px 6px; border-radius:14px; font-size:13px; margin-top:6px; }
/* Reaction picker */
#reaction-picker { position:fixed; display:none; z-index:9000; background:#fff; border-radius:10px; box-shadow:0 6px 24px rgba(0,0,0,0.16); padding:8px; min-width:160px; transition:transform .12s ease, opacity .12s ease; transform:scale(.96); opacity:0; }
#reaction-picker.show { transform:scale(1); opacity:1; }
#reaction-picker .emoji { font-size:18px; padding:6px; cursor:pointer; border-radius:6px; margin:4px; display:inline-flex; align-items:center; justify-content:center; }
#reaction-picker .emoji:hover { background: rgba(0,0,0,0.04); }
.conversation-avatar img, .conversation-avatar-img { max-width: 44px; max-height:44px; border-radius:50%; }
.conversation-avatar-wrapper { width:48px; display:flex; align-items:center; justify-content:center }
.conversation-item.unread { background: rgba(255, 248, 220, 0.9); }
.unread-badge { margin-left: 8px; font-size: 12px; }
/* Tooltip «Copiado» al hacer doble clic en un mensaje */
.copy-feedback {
position: absolute;
top: -26px; left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.72);
color: #fff;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px; font-weight: 600;
white-space: nowrap; pointer-events: none;
animation: cfadeIn .15s ease;
z-index: 100;
}
@keyframes cfadeIn { from { opacity:0; transform: translateX(-50%) translateY(4px); } to { opacity:1; transform: translateX(-50%) translateY(0); } }
</style>
</head>
<body>
<div class="chat-container">
<!-- Sidebar con lista de conversaciones -->
<div class="chat-sidebar">
<div class="sidebar-header">
<div>
<h5 class="mb-0">💬 Conversaciones
<span class="badge bg-warning text-dark ms-2" style="font-size: 9px; padding: 2px 6px; animation: pulse 2s infinite;">
v1.4.0-<?php echo substr(time(), -4); ?>
</span>
</h5>
<!-- Toggle disponibilidad de asesor -->
<div style="margin-top:6px; display:flex; align-items:center; gap:8px;">
<span style="font-size:11px; color:rgba(255,255,255,0.8);">Asesor:</span>
<button id="advisor-mode-btn"
onclick="toggleAdvisorMode()"
style="font-size:11px; padding:2px 10px; border-radius:20px; border:none; cursor:pointer; font-weight:600; transition:all 0.2s;"
title="Activar/desactivar menú de asesor ausente">
</button>
<span id="advisor-mode-label" style="font-size:10px; color:rgba(255,255,255,0.7);"></span>
</div>
</div>
<div>
<a href="index.php" class="text-white text-decoration-none">
<i class="fas fa-arrow-left"></i>
</a>
</div>
</div>
<div class="sidebar-search">
<div class="input-group search-wrapper" style="position:relative;">
<span class="input-group-text bg-transparent border-0">
<i class="fas fa-search text-muted" id="search-icon"></i>
</span>
<input type="text" class="form-control border-0" placeholder="Buscar por nombre, teléfono o mensaje..." id="search-input" autocomplete="off" style="padding-right:28px;">
<span class="search-spinner" id="search-spinner"><i class="fas fa-circle-notch fa-spin"></i></span>
<button class="search-clear" id="search-clear" title="Limpiar búsqueda">&#x2715;</button>
</div>
</div>
<!-- Filtro de conversaciones: Todos / No leídos -->
<div style="padding:8px 12px; display:flex; gap:8px; align-items:center;">
<select id="conversation-filter" class="form-select form-select-sm" style="width:auto;">
<option value="all">Todos</option>
<option value="unread">No leídos</option>
</select>
<small class="text-muted">Mostrar sólo conversaciones con mensajes no leídos</small>
</div>
<div class="conversation-list" id="conversation-list">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i>
<div>Cargando conversaciones...</div>
</div>
</div>
<!--div id="load-more-container" style="padding:10px; text-align:center; display:none;">
<button id="load-more-btn" class="btn btn-sm btn-outline-primary">Cargar más</button>
</div-->
</div>
<!-- Área principal del chat -->
<div class="chat-main">
<div id="no-conversation" class="no-conversation">
<i class="fab fa-whatsapp"></i>
<h4>WhatsApp Bot Manager</h4>
<p>Selecciona una conversación para comenzar a chatear</p>
</div>
<div id="chat-area" style="display: none; height: 100%; flex-direction: column;">
<div class="chat-header">
<button id="sidebar-toggle" class="btn btn-sm btn-outline-light d-md-none me-2" style="border-radius:8px; padding:6px 8px;" title="Volver a conversaciones"><i class="fas fa-bars"></i></button>
<div class="chat-header-avatar" id="chat-avatar">
<i class="fas fa-user"></i>
</div>
<div class="chat-header-info">
<h6 id="chat-name"><span id="chat-name-text">Usuario</span> <button class="btn btn-sm btn-link" id="edit-user-btn" title="Editar usuario"><i class="fas fa-user-edit"></i></button>
<span id="hold-indicator" style="display:none; margin-left:8px; color:#b85; font-weight:600">EN ESPERA</span>
<span id="blocked-indicator"><i class="fas fa-ban me-1"></i>BLOQUEADO</span>
<span id="terms-pending-badge" title="Este usuario aún no ha aceptado los Términos y Condiciones"
style="display:none; margin-left:8px; background:#dc3545; color:#fff; font-size:10px; font-weight:600; padding:2px 7px; border-radius:10px; vertical-align:middle; cursor:default;">
<i class="fas fa-file-contract me-1"></i>T&amp;C Pendiente
</span>
</h6>
<small id="chat-phone">+1234567890</small>
<div style="font-size:12px; margin-top:4px;">
<button id="attend-toggle-btn" class="btn btn-sm btn-outline-light" style="display:none; margin-right:6px;">Atender</button>
<small id="attend-status" class="text-white me-3" style="font-size:0.9rem;"></small>
<button id="release-hold-btn" class="btn btn-sm btn-outline-primary" style="display:none">Liberar espera</button>
</div>
</div>
<div style="margin-left: auto; display:flex; gap:8px; align-items:center;">
<div id="bot-toggle-container" style="display:flex;align-items:center;gap:6px;margin-right:8px;">
<button class="btn btn-sm btn-outline-secondary" id="bot-toggle">Bot: On</button>
</div>
<!-- Botón ver citas del paciente -->
<button class="btn btn-sm btn-outline-warning" id="ver-citas-btn" title="Ver citas de este paciente" onclick="citasModal.abrir()">
<i class="fas fa-calendar-alt"></i>
</button>
<!-- Botón agendar domicilio rápido (sin imagen) -->
<button class="btn btn-sm btn-outline-success" id="agendar-rapido-btn" title="Agendar domicilio" onclick="labDomicilio.abrirRapido()">
<i class="fas fa-house-medical"></i>
</button>
<!-- Botón ir a gestión de domicilios (asignar enfermeras) -->
<a href="lab_domicilios.php" target="_blank" class="btn btn-sm btn-outline-primary" title="Gestión de domicilios — asignar enfermeras">
<i class="fas fa-user-nurse"></i>
</a>
<!-- Botón solicitar archivo grande -->
<button class="btn btn-sm btn-outline-info" id="request-large-file-btn" title="Solicitar archivo grande al cliente">
<i class="fas fa-cloud-upload-alt"></i>
</button>
<!-- Botón programar recordatorio -->
<button class="btn btn-sm btn-success" id="schedule-reminder-btn" title="Programar recordatorio">
<i class="fas fa-calendar-plus"></i>
</button>
<!-- Botón bloquear/desbloquear usuario -->
<button class="btn btn-sm btn-outline-danger" id="block-user-btn" title="Bloquear usuario">
<i class="fas fa-ban"></i>
</button>
<!-- Botón actualizar mensajes -->
<button class="btn btn-sm btn-outline-light" id="refresh-messages-btn" title="Actualizar mensajes"><i class="fas fa-sync-alt"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="mark-unread-btn" title="Marcar conversación como no leída" style="display:none;">Marcar no leído</button>
<button class="btn btn-sm btn-outline-danger" id="delete-conversation-btn" title="Eliminar conversación" style="display:none;"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="chat-conversations" id="chat-conversations">
<!-- Los mensajes se cargan aquí -->
</div>
<!-- Preview de archivo multimedia -->
<div id="media-preview" style="display: none; padding: 10px; background: #f0f0f0; border-top: 1px solid #ddd;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 10px;">
<img id="preview-thumbnail" style="max-width: 60px; max-height: 60px; border-radius: 5px; display: none;">
<div>
<div id="preview-filename" style="font-weight: bold; font-size: 14px;"></div>
<div id="preview-filesize" style="font-size: 12px; color: #666;"></div>
</div>
</div>
<button class="btn btn-sm btn-danger" onclick="cancelMediaUpload()">
<i class="fas fa-times"></i>
</button>
</div>
<input type="text" id="media-caption" class="form-control mt-2" placeholder="Agregar un comentario (opcional)" maxlength="1024">
</div>
<div id="reply-preview" class="reply-preview" style="display:none; align-items:center; justify-content:space-between;">
<div style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" id="reply-preview-text">En respuesta a: (selecciona un mensaje)</div>
<button class="btn btn-sm btn-link" id="cancel-reply-btn" title="Cancelar respuesta">✕</button>
</div>
<div class="chat-input">
<input type="file" id="file-input" accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" style="display: none;">
<button class="btn" id="attach-btn" title="Adjuntar">
📎
</button>
<div id="attach-menu" class="attach-menu" style="display:none;">
<button class="attach-option" data-action="file"><i class="fas fa-file me-2"></i>Enviar archivo</button>
<button class="attach-option" data-action="template"><i class="fas fa-file-alt me-2"></i>Enviar plantilla</button>
<hr style="margin:4px 0; border-color:#eee;">
<button class="attach-option" data-action="large-file" style="color:#075e54; font-weight:600;"><i class="fas fa-cloud-upload-alt me-2"></i>Solicitar archivo grande</button>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<!-- <select id="message-type" class="form-select form-select-sm" style="width:auto; margin-right:8px;">
<option value="text">Texto</option>
<option value="template">Plantilla</option>
</select> -->
<!-- <div id="templateSelectContainer" style="display:none; margin-right:8px;">
<select id="templateSelect" class="form-select form-select-sm">
<option value="">Seleccionar plantilla...</option>
</select>
</div> -->
<div class="quick-replies-wrapper" style="position:relative;">
<button id="quick-replies-toggle" class="btn btn-sm btn-outline-secondary" title="Respuestas rápidas" aria-expanded="false">⚡️</button>
<div id="quick-replies-panel" class="quick-replies-panel" style="display:none; position:absolute; bottom:48px; left:0; z-index:1200; min-width:220px; max-width:420px;">
<div style="padding:6px;">
<input id="quick-replies-search" class="form-control form-control-sm" placeholder="Buscar respuestas o plantillas..." autocomplete="off" />
</div>
<div class="qr-sections" style="max-height:260px; overflow:auto; padding:6px;">
<div class="qr-section">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>Respuestas rápidas</strong>
<small id="qr-count" class="text-muted">0</small>
</div>
<div class="qr-list" style="display:flex; gap:6px; flex-wrap:wrap; margin-top:6px;"></div>
</div>
<hr style="margin:8px 0; border-color:#eee;">
<div class="tpl-section">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>Plantillas</strong>
<small id="tpl-count" class="text-muted">0</small>
</div>
<div class="tpl-list" style="display:flex; gap:6px; flex-wrap:wrap; margin-top:6px;"></div>
</div>
</div>
<div class="text-muted" id="qr-empty" style="padding:8px; display:none;">No hay respuestas rápidas ni plantillas que coincidan.</div>
</div>
</div>
<button class="btn" id="mic-btn" title="Grabar audio" style="margin-left:6px; border:1px solid #ddd;">
<i class="fas fa-microphone"></i>
</button>
</div>
<div id="recording-indicator" style="display:none; padding:8px; background:#fff3cd; border-radius:6px; margin-top:8px;">
<i class="fas fa-microphone text-danger"></i> Grabando... <span id="recording-time">0:00</span>
<button class="btn btn-sm btn-danger" id="stop-recording" style="margin-left:8px;">Detener</button>
<button class="btn btn-sm btn-secondary" id="cancel-recording" style="margin-left:6px;">Cancelar</button>
</div>
<input type="text" placeholder="Escribe un mensaje..." id="message-input" maxlength="4096">
<button class="btn" id="send-btn">
<i class="fas fa-paper-plane"></i>
</button>
<button class="btn btn-danger" id="delete-file-btn" title="Eliminar" style="display:none; margin-left:6px;">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<!-- DETECCIÓN DE VERSIÓN - NO BORRAR -->
<script>
// ESTE LOG DEBE APARECER PRIMERO
console.clear();
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
console.log('%c🚀 WHATSAPP BOT v1.4.0 - Desarrollado por U-Site.app', 'background: #25d366; color: white; padding: 10px 20px; font-size: 18px; font-weight: bold; border-radius: 5px;');
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
window.__APP_VERSION__ = '2.0.0';
</script>
<?php
// Forzar recarga con timestamp actual + microtime para desarrollo
$asset_v = time() . '.' . rand(10000, 99999) . '.' . substr(microtime(true) * 1000, -4);
try {
$chat_common_path = __DIR__ . '/assets/js/chat-common.js';
if (file_exists($chat_common_path)) {
$asset_v = filemtime($chat_common_path) . '.' . rand(10000, 99999) . '.' . substr(microtime(true) * 1000, -4);
}
} catch (Exception $e) {
error_log('Error getting filemtime for chat-common.js: ' . $e->getMessage());
}
?>
<script src="assets/js/chat-common.js?v=<?php echo $asset_v; ?>"></script>
<script>
// ============================================
// 🚀 VERSIÓN ACTUALIZADA - 20 FEBRERO 2026
// ============================================
console.log('%c📦 Asset Version:', 'font-weight: bold; color: #2575fc;', '<?php echo $asset_v; ?>');
console.log('%c✨ Cambios: Descarga automática de media, cache local en media-url.php, parser de sticker, fix extensiones MIME', 'color: #666;');
console.log('============================================');
// Fallback ligero para showAlert (si no existe una implementación global)
if (typeof showAlert === 'undefined') {
function showAlert(message, type = 'info') {
try {
let container = document.getElementById('notification-toasts');
if (!container) {
container = document.createElement('div');
container.id = 'notification-toasts';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'notification-toast small' + (type === 'success' ? ' cool' : '');
const icon = type === 'success' ? '✅' : (type === 'danger' ? '⚠️' : (type === 'warning' ? '⚠' : '️'));
toast.innerHTML = `<span class="nt-icon">${icon}</span><div style="flex:1; font-size:13px">${message}</div>`;
container.appendChild(toast);
// Ensure a sensible minimum duration (1s) so very short toasts don't disappear instantly
const _minToastDuration = 1000;
let _dur = (type === 'success') ? 1000 : 1000;
_dur = Math.max(_dur, _minToastDuration);
setTimeout(() => { if (toast.parentNode) toast.remove(); }, _dur);
} catch (e) {
// fallback to alert if DOM fails
try { alert(message); } catch (e2) { /* ignore */ }
}
}
}
class WhatsAppChat {
constructor() {
console.log('%c✅ WhatsAppChat v1.4.0 - Constructor iniciado', 'background: #10b981; color: white; padding: 4px 8px; font-weight: bold; border-radius: 3px;');
this.currentConversationId = null;
this.currentUserId = null;
this.conversations = []; // Lista de usuarios/conversaciones en el sidebar
console.log('📋 conversations array inicializado:', this.conversations);
this.currentMessages = []; // Mensajes de la conversación activa
// Track shown notifications to avoid duplicates from polling
this._shownNotifications = new Set();
// Track notifications the user dismissed/read locally so they don't reappear
this._dismissedNotifications = new Set();
// Track processed notifications to avoid SSE duplicates
this._processedNotifications = new Set();
// Map of pending ack notifications to retry marking as read (nid => notification)
this._pendingAck = new Map();
// Message pagination / loading state
this.messageLimit = 50;
this.loadingMessages = false;
this.hasMoreMessages = false;
this.earliestMessage = null; // timestamp of earliest loaded message
this.earliestMessageId = null; // id of the earliest loaded message (for stable pagination)
// Filter state: 'all' or 'unread'
this.conversationFilter = 'all';
// Pagination state for conversation list
this.conversationsPage = 1;
this.conversationsLimit = 50;
this.hasMoreConversations = true;
this.loadingConversations = false;
// Track whether the user is actively scrolling/reading to avoid forcing scroll-to-bottom
this._userScrolling = false;
this._userScrollTimer = null;
// Conversation list (sidebar) scroll tracking to preserve position on updates
this._conversationsScrolling = false;
this._conversationsScrollTimer = null;
// Suspend periodic refresh while media playback or recording is active
this._suspendAutoRefresh = false;
this._pendingReloadAfterMedia = false;
// Flag to request a reload after user stops scrolling
this._pendingReloadAfterScroll = false;
// Staged incoming messages when the user is reading history (not near bottom)
this._stagedMessageIds = new Set();
this._stagedCount = 0;
// Store actual staged message objects (for applying later)
this._stagedMessages = [];
// Last message timestamp seen (for delta polling)
this._latestMessage = null;
// Preloaded messages cache used when we explicitly fetch before opening a conversation
this._preloadedMessages = null;
// Removed: automatic "Nuevos mensajes" indicator — we always reload full conversation now
// this._lastRenderMessageCount = 0;
console.log('✅ WhatsAppChat inicializado - conversations array:', this.conversations);
this.init();
}
init() {
console.log('🎯 Iniciando WhatsAppChat v1.4.0...');
this.loadConversations();
this.setupEventListeners();
this.setupAutoRefresh();
this.setupNotificationPolling();
// start background retry loop to ack dismissed notifications on server
this.setupNotificationAckRetry && this.setupNotificationAckRetry();
// Conectar a SSE para notificaciones en tiempo real
this.connectSSE();
// Mostrar confirmación de versión cargada
this.showVersionNotification();
}
showVersionNotification() {
console.log('📢 Mostrando notificación de versión');
const toast = document.createElement('div');
toast.className = 'notification-toast cool';
toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;';
toast.innerHTML = `
<span class="nt-icon">🚀</span>
<div>
<strong>Versión 1.4.0 Cargada</strong><br>
<small style="opacity: 0.9;">20 Feb 2026</small>
</div>
`;
document.body.appendChild(toast);
// Auto-hide después de 4 segundos
setTimeout(() => {
toast.style.transition = 'opacity 0.3s, transform 0.3s';
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
setupNotificationPolling() {
// 🎯 OPTIMIZADO: Las notificaciones ahora solo llegan por SSE
// No necesitamos polling ni carga inicial
console.debug('⚡ setupNotificationPolling: DESHABILITADO - SSE maneja todo en tiempo real');
}
/**
* Conectar a Server-Sent Events para recibir notificaciones en tiempo real
* Esto reemplaza el polling constante y mejora la performance
*/
connectSSE() {
if (this.eventSource) {
try { this.eventSource.close(); } catch(e) {}
}
console.log('Conectando a SSE para eventos en tiempo real...');
try {
// Detectar URL base automáticamente (funciona en cualquier entorno)
const baseUrl = window.location.origin;
const sseUrl = `${baseUrl}/api/sse_events.php?token=demo_token&t=${Date.now()}`;
console.log('SSE URL:', sseUrl);
this.eventSource = new EventSource(sseUrl);
// Evento: conexión establecida
this.eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
const mode = data.mode === 'authenticated' ? '🔐 autenticado' : '🌐 global';
console.log(`✅ SSE conectado (${mode}):`, data);
// Mostrar notificación discreta de conexión
if (typeof showAlert === 'function' && !this._sseConnectedNotified) {
showAlert('Notificaciones en tiempo real activadas', 'success');
this._sseConnectedNotified = true;
}
});
// Evento: nuevo mensaje entrante
this.eventSource.addEventListener('new_message', (e) => {
console.log('📨 SSE new_message recibido:', e.data);
try {
const data = JSON.parse(e.data);
console.log('📨 Datos parseados del mensaje:', data);
// Extraer user_id del mensaje
const userId = data.user_id || data.from_user_id || data.sender_id;
console.log('👤 User ID del mensaje:', userId, '| Conversación actual:', this.currentUserId);
// Si la conversación del mensaje es la actualmente abierta
if (this.currentUserId && String(this.currentUserId) === String(userId)) {
console.log('♻️ Mensaje para conversación ACTIVA');
// Verificar si el usuario está cerca del final del chat
const container = document.getElementById('chat-conversations');
const nearBottom = container ? ((container.scrollHeight - container.scrollTop - container.clientHeight) < 150) : true;
if (nearBottom && !this._userScrolling) {
// Usuario está en el final: recargar mensajes automáticamente
console.log('🔄 Usuario cerca del final, recargando mensajes...');
this.loadMessages(this.currentUserId, true, true).catch(err => {
console.error('Error recargando mensajes:', err);
});
} else {
// Usuario está leyendo historial: usar staged messages
console.log('📜 Usuario leyendo historial, guardando en staging...');
if (!this._stagedMessages) this._stagedMessages = [];
if (!this._stagedMessageIds) this._stagedMessageIds = new Set();
// Crear key única para evitar duplicados
const msgKey = data.message_id || data.id || `${data.created_at}_${data.content?.substring(0,20)}`;
if (!this._stagedMessageIds.has(msgKey)) {
this._stagedMessageIds.add(msgKey);
this._stagedMessages.push(data);
console.log('💾 Mensaje guardado en staging. Total:', this._stagedMessages.length);
// Mostrar indicador de nuevos mensajes
this.showNewMessagesIndicator(this._stagedMessages.length);
} else {
console.log('⏭️ Mensaje duplicado, omitiendo');
}
}
} else {
console.log('📋 Mensaje para OTRA conversación, solo actualizando lista');
}
// Actualizar la lista de conversaciones para mostrar el nuevo mensaje
console.log('📋 Actualizando lista de conversaciones...');
this.updateConversationInList(data);
// Reproducir sonido de notificación solo si no es la conversación activa
if (!this.currentUserId || String(this.currentUserId) !== String(userId)) {
console.log('🔔 Reproduciendo sonido de notificación');
this.playNotificationSound();
}
} catch (err) {
console.error('❌ Error procesando new_message:', err);
}
});
// Evento: nueva conversación detectada
this.eventSource.addEventListener('new_conversation', (e) => {
console.log('💬 Nueva conversación (SSE):', e.data);
const data = JSON.parse(e.data);
// Agregar la conversación a la lista sin recargar todo
this.addConversationToList(data);
// Reproducir sonido
this.playNotificationSound();
});
// Evento: notificación del sistema
this.eventSource.addEventListener('notification', (e) => {
console.log('🔔 Nueva notificación (SSE):', e.data);
try {
const notification = JSON.parse(e.data);
// Deduplicación: evitar procesar la misma notificación múltiples veces
const notificationKey = `notif_${notification.id}_${notification.created_at}`;
if (this._processedNotifications.has(notificationKey)) {
console.debug('⏭️ Notificación ya procesada, omitiendo:', notification.id);
return;
}
this._processedNotifications.add(notificationKey);
// Actualizar la lista de conversaciones con los datos de la notificación
if (notification.user_id && notification.message) {
console.log('📋 Actualizando conversación desde notificación...');
this.updateConversationInList({
user_id: notification.user_id,
message: notification.message,
timestamp: notification.created_at
});
}
// Mostrar toast de notificación
this.showNotificationToast(notification);
} catch (err) {
console.error('Error procesando notificación SSE:', err);
}
});
// Evento: heartbeat (mantener conexión viva)
this.eventSource.addEventListener('heartbeat', (e) => {
// Silencioso, solo mantiene la conexión
});
// Manejo de errores
this.eventSource.onerror = (error) => {
const state = this.eventSource.readyState;
const stateNames = {
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSED'
};
console.warn('❌ Error en SSE:');
console.warn(' Estado:', stateNames[state] || state);
console.warn(' Error:', error);
// Si está intentando conectar, dejar que EventSource lo maneje automáticamente
if (state === EventSource.CONNECTING) {
console.log('⏳ Reconectando automáticamente...');
return;
}
// Si está cerrado, intentar reconectar manualmente
if (state === EventSource.CLOSED) {
console.log('🔄 Conexión cerrada, reconectando en 5 segundos...');
// Cerrar completamente
try {
this.eventSource.close();
this.eventSource = null;
} catch(e) {
console.warn('Error cerrando EventSource:', e);
}
// Reconectar después de delay
if (this._sseReconnectTimeout) {
clearTimeout(this._sseReconnectTimeout);
}
this._sseReconnectTimeout = setTimeout(() => {
if (!this._sseReconnecting) {
console.log('🔌 Intentando reconectar SSE...');
this._sseReconnecting = true;
try {
this.connectSSE();
} catch(e) {
console.error('Error al reconectar SSE:', e);
} finally {
this._sseReconnecting = false;
}
}
}, 5000);
}
};
// Detectar evento de error explícito
this.eventSource.addEventListener('error', (e) => {
if (e.data) {
try {
const errorData = JSON.parse(e.data);
console.error('❌ SSE Error:', errorData.message);
// Mostrar notificación al usuario
if (typeof showAlert === 'function') {
showAlert('Error de conexión: ' + errorData.message, 'warning');
}
} catch(err) {
console.error('❌ SSE Error (raw):', e.data);
}
}
});
} catch (error) {
console.error('Error conectando SSE:', error);
}
}
/**
* Actualizar una conversación en la lista (sin recargar todo)
*/
updateConversationInList(data) {
try {
console.log('📝 updateConversationInList INICIADO:', data);
// Asegurar que conversations esté inicializado
if (!this.conversations || !Array.isArray(this.conversations)) {
console.warn('⚠️ conversations array vacío, recargando lista completa...');
this.loadConversations();
return;
}
// Extraer user_id de diferentes formatos posibles
const userId = data.user_id || data.from_user_id || data.sender_id;
if (!userId) {
console.warn('⚠️ No se pudo obtener user_id de los datos:', data);
return;
}
console.log('👤 User ID extraído:', userId);
// Si no viene el mensaje, recargar la lista completa para obtener datos actualizados
if (!data.message && !data.content && !data.text && !data.last_message) {
console.log('⚠️ Evento SSE sin contenido de mensaje, recargando lista completa...');
// Forzar recarga temporal (sin await, ejecutar en background)
const wasLoaded = this._conversationsLoaded;
this._conversationsLoaded = false;
this.loadConversations().then(() => {
this._conversationsLoaded = wasLoaded;
});
return;
}
// Extraer mensaje de diferentes formatos
const message = data.message || data.content || data.text || data.last_message || 'Nuevo mensaje';
const timestamp = data.timestamp || data.created_at || new Date().toISOString();
console.log('💬 Mensaje extraído:', message);
console.log('⏰ Timestamp:', timestamp);
// Buscar la conversación en el array
const existingIndex = this.conversations.findIndex(c => String(c.user_id) === String(userId));
if (existingIndex !== -1) {
console.log('✅ Conversación encontrada en índice:', existingIndex);
// Actualizar conversación existente
const conv = this.conversations[existingIndex];
conv.last_message = message;
conv.last_time = timestamp;
// Solo incrementar unread_count si no es la conversación activa
if (String(this.currentUserId) !== String(userId)) {
conv.unread_count = (conv.unread_count || 0) + 1;
console.log('📬 unread_count incrementado a:', conv.unread_count);
} else {
// Si es la conversación activa, mantener unread_count en 0
conv.unread_count = 0;
console.log('✅ Conversación activa, unread_count = 0');
}
// Mover al inicio de la lista
this.conversations.splice(existingIndex, 1);
this.conversations.unshift(conv);
console.log('⬆️ Conversación movida al inicio');
} else {
console.log(' Conversación no existe, agregando nueva...');
// Nueva conversación, agregar al inicio
this.conversations.unshift({
user_id: userId,
name: data.name || data.sender_name || data.phone_number || `Usuario ${userId}`,
phone_number: data.phone_number || data.phone || '',
last_message: message,
last_time: timestamp,
unread_count: String(this.currentUserId) !== String(userId) ? 1 : 0
});
console.log('✅ Nueva conversación agregada');
}
// Re-renderizar solo la lista de conversaciones
console.log('🔄 Re-renderizando lista de conversaciones...');
console.log('📊 Total conversaciones:', this.conversations.length);
console.log('🔍 Filtro activo:', this.conversationFilter);
if (typeof this.renderConversations === 'function') {
console.log('✅ Llamando a renderConversations()...');
this.renderConversations();
console.log('✅ renderConversations() ejecutado correctamente');
} else {
console.error('❌ renderConversations no es una función!');
}
} catch (error) {
console.error('❌ Error updateConversationInList:', error);
console.error('Stack trace:', error.stack);
}
}
/**
* Agregar una conversación nueva a la lista
*/
addConversationToList(data) {
try {
console.log(' Agregando nueva conversación:', data);
// Asegurar que conversations esté inicializado
if (!this.conversations || !Array.isArray(this.conversations)) {
console.warn('⚠️ conversations array no estaba inicializado, recargando...');
this.loadConversations();
return;
}
// Extraer user_id
const userId = data.user_id || data.from_user_id || data.sender_id;
if (!userId) {
console.warn('⚠️ No se pudo obtener user_id');
return;
}
// Verificar si ya existe
const exists = this.conversations.some(c => String(c.user_id) === String(userId));
if (exists) {
console.log('🔄 Conversación ya existe, actualizando...');
return this.updateConversationInList(data);
}
// Extraer datos
const message = data.message || data.content || data.text || 'Nueva conversación';
const timestamp = data.timestamp || data.created_at || new Date().toISOString();
const name = data.name || data.sender_name || data.phone_number || `Usuario ${userId}`;
// Agregar al inicio
this.conversations.unshift({
user_id: userId,
name: name,
phone_number: data.phone_number || data.phone || '',
last_message: message,
last_time: timestamp,
unread_count: String(this.currentUserId) !== String(userId) ? (data.message_count || 1) : 0
});
console.log('✅ Conversación agregada, re-renderizando...');
// Re-renderizar lista
this.renderConversations();
} catch (error) {
console.error('❌ Error agregando conversación:', error);
}
}
async loadNotifications() {
// 🎯 ELIMINADO: Las notificaciones ahora solo llegan por SSE en tiempo real
// No se hace fetch a get_notifications.php
console.debug('⚡ loadNotifications: SSE maneja todas las notificaciones en tiempo real');
return;
}
// SIMPLIFICADO: Las notificaciones ahora son solo en tiempo real
// No se guardan en BD ni necesitan marcarse como leídas
async showNotificationToast(notification) {
// Create or reuse container
const containerId = 'notification-toasts';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
// Determine an id to dedupe notifications (prefer server id)
const nid = notification && notification.id ? String(notification.id) : ('msg:' + String((notification && notification.message) || '').slice(0,200));
// If the user already dismissed/acked this notification earlier, skip it
if (this._dismissedNotifications.has(nid)) {
console.debug('Notification previously dismissed, skipping', nid);
return;
}
if (this._shownNotifications.has(nid)) {
// already shown in this session
console.debug('Notification already shown, skipping', nid);
return;
}
// mark as shown immediately (solo en memoria, no en servidor)
this._shownNotifications.add(nid);
const toast = document.createElement('div');
// compact by default
toast.className = 'notification-toast small';
// Detect special "attention" notifications (e.g. user sent documents or similar system events)
const isAttention = (
// explicit attention flags
notification.type === 'attention' || notification.system === 'attention' || notification.level === 'attention' || notification.attention ||
// known event types/tags for user-sent documents
notification.type === 'usersentdocuments' || notification.tag === 'usersentdocuments' || notification.system === 'usersentdocuments' ||
// file upload notifications (large file request feature)
notification.type === 'file_uploaded' || notification.type === 'file_request_sent'
);
if (notification.cool || notification.type === 'cool') toast.classList.add('cool');
if (notification.level === 'urgent' || notification.urgent) toast.classList.add('urgent');
if (isAttention) toast.classList.add('attention');
const iconSpan = document.createElement('span');
iconSpan.className = 'nt-icon';
iconSpan.textContent = notification.icon || (isAttention ? '📎' : (notification.cool ? '✨' : (notification.level === 'urgent' ? '⚠️' : '🔔')));
toast.appendChild(iconSpan);
const msg = document.createElement('div');
msg.style.flex = '1';
msg.style.fontSize = '13px';
msg.style.lineHeight = '1.2';
msg.textContent = notification.message || 'Nuevo evento';
toast.appendChild(msg);
const actions = document.createElement('div');
actions.className = 'nt-actions';
const removeToastLocal = () => {
if (toast.parentNode) toast.remove();
if (this._shownNotifications.has(nid)) this._shownNotifications.delete(nid);
};
const openBtn = document.createElement('button');
openBtn.className = notification.cool ? 'btn btn-sm btn-light' : (isAttention ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-primary');
openBtn.textContent = (notification.open_label && notification.open_label !== 'Ver') ? notification.open_label : 'Abrir';
openBtn.onclick = async () => {
// Marcar como descartada localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
// navigate to conv or open media if present
let data = {};
try { data = notification.data ? JSON.parse(notification.data) : {}; } catch(e) {}
const userId = data.user_id || notification.user_id;
const fileUrl = data.file_url || data.url || null;
if (fileUrl && this.currentUserId && String(this.currentUserId) === String(userId)) {
try { openMediaLightbox(fileUrl, data.file_name || ''); } catch (e) { console.warn('openMediaLightbox failed', e); }
} else if (userId) {
const conv = this.conversations.find(c => c.user_id == userId);
if (conv) {
// Prefetch messages then open conversation using preloaded data to avoid double-fetch
try {
const url = `get_user_messages.php?user_id=${encodeURIComponent(userId)}&limit=${this.messageLimit}`;
const resp = await this.apiCall(url);
if (resp && resp.success) {
this._preloadedMessages = { userId: userId, resp: resp };
}
} catch (e) { console.warn('Prefetch messages failed', e); }
this.openConversation(conv.user_id, conv.name, conv.phone_number);
} else {
try {
const url = `get_user_messages.php?user_id=${encodeURIComponent(userId)}&limit=${this.messageLimit}`;
const resp = await this.apiCall(url);
if (resp && resp.success) {
this._preloadedMessages = { userId: userId, resp: resp };
}
} catch (e) { console.warn('Prefetch messages failed', e); }
this.openConversation(userId, 'Usuario', '');
}
}
};
const dismiss = document.createElement('button');
dismiss.className = 'btn btn-sm btn-outline-secondary';
dismiss.textContent = '×';
dismiss.title = 'Descartar';
dismiss.onclick = async () => {
// Marcar como descartada localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
};
actions.appendChild(openBtn);
actions.appendChild(dismiss);
toast.appendChild(actions);
container.appendChild(toast);
// If attention notification and related to current open conversation, insert a system message into the chat view
try {
const dataObj = notification.data ? (typeof notification.data === 'string' ? JSON.parse(notification.data) : notification.data) : {};
const userId = dataObj.user_id || notification.user_id;
if (isAttention && userId && this.currentUserId && String(this.currentUserId) === String(userId) && typeof this.showSystemNotificationInChat === 'function') {
this.showSystemNotificationInChat(notification);
}
} catch (e) { console.warn('showNotificationToast -> showSystemNotificationInChat failed', e); }
// Auto remove (shorter for compact notifications) with minimum enforced
// Reducir mínimo y valores por defecto a 1 segundo (1000 ms)
const _minToastDuration = 1000;
let timeout = notification.duration ? Number(notification.duration) : (notification.cool ? 1000 : 1000);
if (!isFinite(timeout) || timeout <= 0) timeout = (notification.cool ? 1000 : 1000);
timeout = Math.max(timeout, _minToastDuration);
setTimeout(() => {
// Auto-descartar: solo limpiar localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
}, timeout);
}
showSystemNotificationInChat(notification) {
try {
const data = notification.data ? (typeof notification.data === 'string' ? JSON.parse(notification.data) : notification.data) : {};
const userId = data.user_id || notification.user_id;
// Only show in currently open conversation
if (!userId || String(this.currentUserId) !== String(userId)) return;
const container = document.getElementById('chat-conversations');
if (!container) return;
const el = document.createElement('div');
el.className = 'message incoming';
el.dataset.notificationId = String(notification.id || '');
const bubble = document.createElement('div');
bubble.className = 'message-bubble message-template system';
// Friendly title and message (prefer explicit fields if present)
const title = notification.title || data.title || 'Documentos recibidos';
const msg = notification.message || data.text || data.message || (data.summary || 'El usuario envió documentos.');
// File info (if available)
const fileName = data.file_name || data.filename || data.file || null;
const fileUrl = data.file_url || data.url || null;
const fileSize = data.file_size || data.size || null;
// Build inner HTML
const parts = [];
parts.push(`<span class="mt-icon">📎</span>`);
parts.push(`<div style="flex:1">`);
parts.push(`<div style="font-weight:700; margin-bottom:6px">${escapeHtml(String(title))}</div>`);
parts.push(`<div style="font-size:13px; color:#444">${escapeHtml(String(msg))}</div>`);
if (fileName) {
parts.push(`<div style="margin-top:8px; display:flex; gap:8px; align-items:center;">
<i class="${getFileIcon(fileName)}"></i>
<div style="display:flex; flex-direction:column;">
<div class="document-name">${escapeHtml(fileName)}</div>
<div class="document-size text-muted" style="font-size:12px">${fileSize ? this.formatFileSize(Number(fileSize)) : ''}</div>
</div>
</div>`);
}
parts.push(`</div>`);
// Actions
parts.push(`<div style="margin-left:8px; display:flex; gap:6px; align-items:center">`);
if (fileUrl) {
parts.push(`<a href="${escapeHtml(fileUrl)}" target="_blank" class="btn btn-sm btn-outline-primary">Abrir</a>`);
parts.push(`<button class="btn btn-sm btn-primary download-doc-btn">Descargar</button>`);
}
parts.push(`</div>`);
bubble.innerHTML = parts.join('');
el.appendChild(bubble);
container.appendChild(el);
this.scrollToBottom();
// Attach handlers
const downloadBtn = bubble.querySelector('.download-doc-btn');
if (downloadBtn) downloadBtn.addEventListener('click', () => {
if (fileUrl) {
const a = document.createElement('a');
a.href = fileUrl;
a.download = fileName || '';
document.body.appendChild(a);
a.click();
a.remove();
}
});
} catch (e) {
console.warn('showSystemNotificationInChat failed', e);
}
}
// Mostrar mensaje de estado (persistente) en la conversación activa
showStatusMessageInChat(userId, text) {
try {
if (!userId || !text) return;
if (String(this.currentUserId) !== String(userId)) return;
const container = document.getElementById('chat-conversations');
if (!container) return;
// eliminar status previos para evitar duplicados
Array.from(container.querySelectorAll('.message-template.system.system-status')).forEach(el => el.parentNode && el.parentNode.removeChild(el));
const el = document.createElement('div');
el.className = 'message incoming';
el.dataset.status = 'service';
const bubble = document.createElement('div');
bubble.className = 'message-bubble message-template system system-status';
bubble.innerHTML = `<div style="display:flex; gap:8px; align-items:center"><span class="mt-icon">👩‍⚕️</span><div style="flex:1"><div style="font-weight:700">${escapeHtml(String(text))}</div></div></div>`;
el.appendChild(bubble);
container.appendChild(el);
this.scrollToBottom();
} catch (e) { console.warn('showStatusMessageInChat failed', e); }
}
setupEventListeners() {
// Búsqueda de conversaciones con debounce
let searchTimeout;
const searchInput = document.getElementById('search-input');
const searchClear = document.getElementById('search-clear');
const searchSpinner = document.getElementById('search-spinner');
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const val = e.target.value;
// Mostrar/ocultar botón X
if (searchClear) searchClear.style.display = val ? 'block' : 'none';
// Mostrar spinner mientras espera debounce
if (searchSpinner && val) searchSpinner.style.display = 'block';
searchTimeout = setTimeout(async () => {
if (searchSpinner) searchSpinner.style.display = 'none';
await this.searchConversations(val);
}, 300);
});
if (searchClear) {
searchClear.addEventListener('click', () => {
searchInput.value = '';
searchClear.style.display = 'none';
if (searchSpinner) searchSpinner.style.display = 'none';
clearTimeout(searchTimeout);
this.searchConversations('');
searchInput.focus();
});
}
// Envío de mensajes
document.getElementById('send-btn').addEventListener('click', () => {
this.sendMessage();
});
// Botón actualizar mensajes
const refreshBtn = document.getElementById('refresh-messages-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
if (!this.currentUserId) return;
refreshBtn.disabled = true;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt fa-spin"></i>';
try {
await this.loadMessages(this.currentUserId, true, true);
console.log('✅ Mensajes actualizados manualmente');
} catch (e) {
console.error('Error actualizando mensajes:', e);
} finally {
refreshBtn.disabled = false;
refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i>';
}
});
}
// Forward handler: open preview when user chooses to forward an existing message
this.forwardMessage = function(messageId) {
try {
const input = document.getElementById('message-input');
if (input) {
input.dataset.forwardTo = messageId;
}
this.showReplyPreview(messageId, 'forward');
// Optionally focus the recipient selection by showing a hint or opening the sidebar
} catch (e) {
console.warn('forwardMessage failed', e);
}
};
// Edit user name button
const editUserBtn = document.getElementById('edit-user-btn');
if (editUserBtn) editUserBtn.addEventListener('click', () => this.promptEditUser());
// Reply prompt helper: when user clicks reply on a message
this.promptReply = function(messageId) {
const input = document.getElementById('message-input');
if (!input) return;
input.dataset.replyTo = messageId;
input.dataset.forwardTo = null;
this.showReplyPreview(messageId, 'reply');
input.focus();
};
// Reaction picker: open a small emoji palette near the click
this.openReactionPicker = function(ev, messageId) {
if (ev && ev.stopPropagation) ev.stopPropagation();
const picker = document.getElementById('reaction-picker');
if (!picker) return;
this._pendingReactionMessage = messageId;
// position picker near event or center
picker.style.display = 'block';
picker.classList.remove('show');
// compute position
let left = (window.innerWidth / 2) - (picker.offsetWidth / 2);
let top = (window.innerHeight / 2) - (picker.offsetHeight / 2);
if (ev && ev.clientX) {
left = Math.min(Math.max(8, ev.clientX - 40), window.innerWidth - picker.offsetWidth - 8);
top = Math.min(Math.max(8, ev.clientY - 24), window.innerHeight - picker.offsetHeight - 8);
}
picker.style.left = left + 'px';
picker.style.top = top + 'px';
// small delay to allow CSS transition
setTimeout(() => picker.classList.add('show'), 10);
};
// Hide reaction picker on document click
document.addEventListener('click', (ev) => {
const picker = document.getElementById('reaction-picker');
if (picker && picker.style.display === 'block' && !picker.contains(ev.target)) {
picker.classList.remove('show');
setTimeout(()=>{ picker.style.display='none'; },160);
}
});
// Attach click handler for emojis (event delegation)
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (t && t.dataset && t.dataset.emoji) {
ev.stopPropagation();
const emoji = t.dataset.emoji;
const mid = this._pendingReactionMessage;
if (!mid) return;
try {
const resp = await fetch('api/react_message.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ message_id: mid, emoji: emoji }), cache: 'no-store' });
const j = await resp.json();
if (j && j.success) {
// update local model and UI
const m = this.currentMessages.find(x => x.message_id == mid || x.id == mid);
if (m) m.reaction_emoji = emoji;
this.updateMessageReactionInView && this.updateMessageReactionInView(mid, emoji);
} else {
showAlert && showAlert('Error aplicando reacción', 'danger');
}
} catch (err) {
console.error('Reaction error', err);
showAlert && showAlert('Error aplicando reacción', 'danger');
} finally {
const picker = document.getElementById('reaction-picker');
if (picker) {
picker.classList.remove('show');
setTimeout(()=>{ picker.style.display='none'; },160);
}
this._pendingReactionMessage = null;
}
}
});
// Message type (text/template)
const typeSelect = document.getElementById('message-type');
if (typeSelect) {
typeSelect.addEventListener('change', (e) => {
const tplContainer = document.getElementById('templateSelectContainer');
tplContainer.style.display = (e.target.value === 'template') ? 'inline-block' : 'none';
if (typeof updateSendMicVisibility === 'function') updateSendMicVisibility();
});
}
// Quick replies toggle behavior: close on outside click
const qrToggle = document.getElementById('quick-replies-toggle');
const qrPanel = document.getElementById('quick-replies-panel');
if (qrToggle && qrPanel) {
qrToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
const isOpen = qrPanel.style.display === 'block';
qrPanel.style.display = isOpen ? 'none' : 'block';
qrToggle.setAttribute('aria-expanded', String(!isOpen));
// Focus search when panel opens
if (!isOpen) {
setTimeout(() => {
const s = document.getElementById('quick-replies-search');
if (s) { s.value = ''; s.focus(); s.dispatchEvent(new Event('input')); }
}, 80);
}
});
qrPanel.addEventListener('click', (ev) => { ev.stopPropagation(); });
document.addEventListener('click', () => { qrPanel.style.display = 'none'; qrToggle.setAttribute('aria-expanded','false'); });
// Quick replies search/filter behavior
const qrSearch = document.getElementById('quick-replies-search');
if (qrSearch) {
const filterLists = () => {
const q = (qrSearch.value || '').trim().toLowerCase();
const qrList = qrPanel.querySelector('.qr-list');
const tplList = qrPanel.querySelector('.tpl-list');
let qrMatches = 0, tplMatches = 0;
if (qrList) Array.from(qrList.children).forEach(b => {
const t = (b.textContent || '').toLowerCase();
const ok = q === '' || t.indexOf(q) !== -1;
b.style.display = ok ? 'inline-block' : 'none';
if (ok) qrMatches++;
});
if (tplList) Array.from(tplList.children).forEach(b => {
const t = (b.textContent || '').toLowerCase();
const ok = q === '' || t.indexOf(q) !== -1;
b.style.display = ok ? 'inline-block' : 'none';
if (ok) tplMatches++;
});
const qrCount = document.getElementById('qr-count'); if (qrCount) qrCount.textContent = qrMatches;
const tplCount = document.getElementById('tpl-count'); if (tplCount) tplCount.textContent = tplMatches;
const empty = document.getElementById('qr-empty'); if (empty) empty.style.display = (qrMatches + tplMatches) ? 'none' : 'block';
};
qrSearch.addEventListener('input', filterLists);
}
}
// Mic / recording
const micBtn = document.getElementById('mic-btn');
const sendBtn = document.getElementById('send-btn');
const messageInput = document.getElementById('message-input');
// Move mic button to be next to send (so it appears in the send slot)
try {
if (micBtn && sendBtn && micBtn.parentNode) {
sendBtn.parentNode.insertBefore(micBtn, sendBtn);
}
} catch (e) { /* ignore */ }
// Use lexical captured self to avoid 'this' binding issues in event handlers
const self = this;
if (micBtn) {
micBtn.addEventListener('click', (ev) => {
ev.preventDefault();
// Ensure recording functions are available (they are initialized when opening a conversation
// but can be invoked earlier; initialize lazily if needed)
if (typeof self.startRecording !== 'function' && typeof self.initRecordingHandlers === 'function') {
try { self.initRecordingHandlers(); } catch(e) { console.warn('initRecordingHandlers failed', e); }
}
// While recording: clicking mic cancels the recording (as requested)
if (self._mediaRecorder && self._mediaRecorder.state === 'recording') {
try { self.cancelRecording(); } catch (e) { console.warn('cancelRecording failed', e); }
} else {
try { self.startRecording(); } catch (e) { console.warn('startRecording failed', e); alert('No se pudo iniciar la grabación.'); }
}
});
}
// Toggle visibility: show mic when input empty, show send when there's text, a file selected, or template mode
const updateSendMicVisibility = () => {
const text = (messageInput && messageInput.value) ? messageInput.value.trim() : '';
const typeSelect = document.getElementById('message-type');
const isTemplate = typeSelect && typeSelect.value === 'template';
// Consider both selectedFile and visible media-preview as indicators of media pending send
const previewEl = document.getElementById('media-preview');
const fileSelected = !!this.selectedFile || (previewEl && previewEl.style.display && previewEl.style.display !== 'none');
// If currently recording, show mic and don't switch to send
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') {
if (sendBtn) { sendBtn.style.display = 'none'; }
if (micBtn) { micBtn.style.display = 'inline-flex'; }
return;
}
// If there's a file selected or there's text/template, show send button (media overrides mic)
if (fileSelected || text.length > 0 || isTemplate) {
if (sendBtn) { sendBtn.style.display = 'inline-block'; }
if (micBtn) { micBtn.style.display = 'none'; }
} else {
// Force mic visible when input empty and no file selected
if (sendBtn) { sendBtn.style.display = 'none'; }
if (micBtn) { micBtn.style.display = 'inline-flex'; }
}
};
if (messageInput) {
messageInput.addEventListener('input', updateSendMicVisibility);
}
// Expose small helper to other methods
this._updateSendMicVisibility = updateSendMicVisibility;
// Doble clic en burbuja de mensaje → copiar texto al portapapeles
const chatConvContainer = document.getElementById('chat-conversations');
if (chatConvContainer) {
chatConvContainer.addEventListener('dblclick', (e) => {
const bubble = e.target.closest('.message-bubble');
if (!bubble) return;
const contentEl = bubble.querySelector('.message-content');
if (!contentEl) return;
const text = (contentEl.innerText || contentEl.textContent || '').trim();
if (!text) return;
const doFeedback = () => this._showCopyToast(bubble);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(doFeedback).catch(() => {
try { document.execCommand('copy'); doFeedback(); } catch(e2) {}
});
} else {
try {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(contentEl);
sel.removeAllRanges(); sel.addRange(range);
document.execCommand('copy');
sel.removeAllRanges();
doFeedback();
} catch(e2) {}
}
});
}
// File input selected -> show send button
const fileInput = document.getElementById('file-input');
if (fileInput) {
fileInput.addEventListener('change', (ev) => {
if (ev.target.files && ev.target.files.length) {
// existing showMediaPreview handles storing this.selectedFile
this.showMediaPreview(ev.target.files[0]);
}
updateSendMicVisibility();
});
}
// Initialize visibility
setTimeout(updateSendMicVisibility, 10);
// Attach '+' menu handling
const attachBtn = document.getElementById('attach-btn');
const attachMenu = document.getElementById('attach-menu');
if (attachBtn && attachMenu) {
attachBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const isOpen = attachMenu.style.display === 'block';
// position menu under the button
const rect = attachBtn.getBoundingClientRect();
attachMenu.style.left = (rect.left) + 'px';
attachMenu.style.top = (rect.bottom + 8) + 'px';
attachMenu.style.display = isOpen ? 'none' : 'block';
});
// Option clicks
attachMenu.addEventListener('click', (ev) => {
const action = ev.target && ev.target.dataset ? ev.target.dataset.action : null;
if (!action) return;
ev.stopPropagation();
attachMenu.style.display = 'none';
if (action === 'file') {
if (fileInput) fileInput.click();
} else if (action === 'large-file') {
self.requestLargeFile();
} else if (action === 'template') {
// switch to template mode and focus selector
const typeSelect = document.getElementById('message-type');
if (typeSelect) {
typeSelect.value = 'template';
const evt = new Event('change');
typeSelect.dispatchEvent(evt);
const tplSel = document.getElementById('templateSelect');
if (tplSel) tplSel.focus();
updateSendMicVisibility();
}
}
});
// close when clicking outside
document.addEventListener('click', (ev) => {
if (attachMenu && attachMenu.style.display === 'block' && !attachMenu.contains(ev.target) && ev.target !== attachBtn) {
attachMenu.style.display = 'none';
}
});
}
// Mobile: sidebar toggle
const sidebar = document.querySelector('.chat-sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', (ev) => {
ev.preventDefault();
// toggle mobile-hidden class
sidebar.classList.toggle('mobile-hidden');
});
// ensure sidebar hidden by default on small screens
const applyInitialMobile = () => {
const chatAreaEl = document.getElementById('chat-area');
const sidebarToggleBtn = document.getElementById('sidebar-toggle');
if (window.innerWidth <= 768) {
// On small screens, show the conversations list by default
sidebar.classList.remove('mobile-hidden');
// hide chat area if no conversation selected; otherwise show chat and hide sidebar
if (chatAreaEl) {
if (!this.currentUserId) {
chatAreaEl.style.display = 'none';
} else {
chatAreaEl.style.display = 'flex';
sidebar.classList.add('mobile-hidden');
}
}
if (sidebarToggleBtn) sidebarToggleBtn.style.display = 'inline-block';
} else {
// Desktop: show both panels
sidebar.classList.remove('mobile-hidden');
if (chatAreaEl) chatAreaEl.style.display = this.currentUserId ? 'flex' : 'none';
if (sidebarToggleBtn) sidebarToggleBtn.style.display = 'none';
}
};
applyInitialMobile();
window.addEventListener('resize', applyInitialMobile);
}
const stopRecBtn = document.getElementById('stop-recording');
if (stopRecBtn) stopRecBtn.addEventListener('click', () => this.stopRecording());
const cancelRecBtn = document.getElementById('cancel-recording');
if (cancelRecBtn) cancelRecBtn.addEventListener('click', () => this.cancelRecording());
document.getElementById('message-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.sendMessage();
}
});
// Permitir pegar imágenes desde el portapapeles: convierte el contenido pegado en File y muestra el preview
document.getElementById('message-input').addEventListener('paste', (e) => {
try {
const cd = (e.clipboardData || window.clipboardData);
if (!cd) return;
// ─── Comprimir imagen pegada a JPEG ─────────────────────────────────────
// El contenedor usa OpenSSL 3.x que hace renegociación TLS con Facebook
// para payloads >~300KB → errno 55 "Send failure". Solución: forzar
// que la imagen quede bajo 250KB mediante calidad progresiva + escala.
const TARGET_BYTES = 250 * 1024; // 250KB
const MAX_DIM = 1920; // máximo ancho/alto antes de escalar
const compressToJpeg = (blob) => new Promise((resolve) => {
// Si ya es pequeño JPEG/WebP, no recomprimir
if ((blob.type === 'image/jpeg' || blob.type === 'image/webp') && blob.size <= TARGET_BYTES) {
resolve(blob);
return;
}
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
// Escalar si la imagen es más grande que MAX_DIM
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX_DIM || h > MAX_DIM) {
const ratio = Math.min(MAX_DIM / w, MAX_DIM / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; // fondo blanco para PNGs con transparencia
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
// Calidades a intentar hasta quedar bajo TARGET_BYTES
const qualities = [0.85, 0.70, 0.55, 0.40, 0.25];
let idx = 0;
const tryNext = () => {
if (idx >= qualities.length) { resolve(blob); return; } // fallback
canvas.toBlob((b) => {
if (!b) { resolve(blob); return; }
if (b.size <= TARGET_BYTES || idx === qualities.length - 1) {
console.log(`Paste compress: ${(blob.size/1024).toFixed(0)}KB → ${(b.size/1024).toFixed(0)}KB (q=${qualities[idx]} ${w}×${h}px)`);
resolve(b);
} else {
idx++;
tryNext();
}
}, 'image/jpeg', qualities[idx]);
};
tryNext();
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(blob); };
img.src = url;
});
// Helper: convertir base64 a Blob
const b64ToBlob = (b64, mime) => {
const bytes = atob(b64);
const len = bytes.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) arr[i] = bytes.charCodeAt(i);
return new Blob([arr], { type: mime });
};
// Helper: procesar un blob de imagen (comprimir + preview)
const handleImageBlob = async (blob, originalName) => {
const compressed = await compressToJpeg(blob);
const name = (originalName || 'pasted_image').replace(/\.[^.]+$/, '') + '.jpg';
const file = new File([compressed], name, { type: 'image/jpeg' });
const max = this.getMaxFileSize(file.type);
if (file.size > max) {
alert('El archivo es demasiado grande. Máximo: ' + (max / 1024 / 1024).toFixed(0) + 'MB');
return;
}
console.log(`Imagen pegada comprimida: ${(blob.size/1024).toFixed(0)}KB → ${(file.size/1024).toFixed(0)}KB (JPEG 0.85)`);
this.showMediaPreview(file);
};
// 1) Si hay archivos directos en clipboard (Chrome/Edge)
if (cd.files && cd.files.length) {
for (const file of cd.files) {
if (file && file.type && file.type.startsWith('image/')) {
e.preventDefault && e.preventDefault();
handleImageBlob(file, file.name);
return;
}
}
}
// 2) Items (Safari / otros) - buscar imagen o HTML con data URI
if (cd.items && cd.items.length) {
for (const item of cd.items) {
try {
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
e.preventDefault && e.preventDefault();
const blob = item.getAsFile();
if (blob) {
handleImageBlob(blob, blob.name || 'pasted_image.png');
return;
}
} else if (item.kind === 'string' && (item.type === 'text/html' || item.type === 'text/plain')) {
// Extraer data URI desde HTML si existe
item.getAsString((s) => {
const m = s && s.match ? s.match(/src=["']data:(image\/[^;]+);base64,([^"']+)["']/i) : null;
if (m) {
try {
const blob = b64ToBlob(m[2], m[1]);
handleImageBlob(blob, 'pasted_image.png');
} catch (err) { console.warn('paste image convert failed', err); }
}
});
// no hacer return inmediato: seguir buscando otros items
}
} catch (inner) { console.warn('clipboard item parse failed', inner); }
}
}
} catch (err) {
console.warn('paste handler failed', err);
}
});
// Adjuntar archivos
document.getElementById('attach-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', (e) => {
this.handleFileSelect(e);
});
// Carga paginada: botón "Cargar más" y scroll infinito
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', () => this.loadMoreConversations());
}
const convList = document.getElementById('conversation-list');
if (convList) {
convList.addEventListener('scroll', () => {
// mark user scrolling in sidebar so updates don't jump their view
try {
this._conversationsScrolling = true;
if (this._conversationsScrollTimer) clearTimeout(this._conversationsScrollTimer);
this._conversationsScrollTimer = setTimeout(() => { this._conversationsScrolling = false; this._conversationsScrollTimer = null; }, 600);
} catch (e) { /* ignore */ }
if (this.hasMoreConversations && !this.loadingConversations && (convList.scrollTop + convList.clientHeight >= convList.scrollHeight - 60)) {
this.loadMoreConversations();
}
});
}
// Filtro: Todos / No leídos
const filterSelect = document.getElementById('conversation-filter');
if (filterSelect) {
filterSelect.value = this.conversationFilter;
filterSelect.addEventListener('change', (e) => {
this.conversationFilter = e.target.value || 'all';
console.log('🔍 Filtro cambiado a:', this.conversationFilter);
// Re-renderizar la lista actual sin recargar desde el servidor
this.renderConversations();
// También recargar desde el servidor para asegurar datos actualizados
// (pero esto es secundario y puede ser en background)
this.loadConversations(1, false).catch(err => {
console.warn('Error recargando conversaciones después de cambiar filtro:', err);
});
});
}
// Botón de programar recordatorio
const reminderBtn = document.getElementById('schedule-reminder-btn');
if (reminderBtn) {
reminderBtn.addEventListener('click', () => this.showReminderModal());
}
// Botón solicitar archivo grande (header)
const requestLargeFileBtn = document.getElementById('request-large-file-btn');
if (requestLargeFileBtn) {
requestLargeFileBtn.addEventListener('click', () => this.requestLargeFile());
}
// Botón de guardar recordatorio
const saveReminderBtn = document.getElementById('save-reminder-btn');
if (saveReminderBtn) {
saveReminderBtn.addEventListener('click', () => this.saveReminder());
}
// Selector de plantilla en recordatorio
const reminderTemplate = document.getElementById('reminder-template');
if (reminderTemplate) {
reminderTemplate.addEventListener('change', () => this.handleReminderTemplateChange());
}
// Botón de enviar plantilla con variables
const sendTemplateWithVarsBtn = document.getElementById('send-template-with-vars-btn');
if (sendTemplateWithVarsBtn) {
sendTemplateWithVarsBtn.addEventListener('click', () => this.sendTemplateWithVariables());
}
}
setupAutoRefresh() {
// NOTA: Todos los sistemas de polling automático fueron ELIMINADOS
//
// SSE maneja TODAS las actualizaciones en tiempo real:
//
// 1. new_message → Recarga mensajes de conversación activa
// Ver línea ~1103: this.loadMessages(this.currentUserId, false, true)
//
// 2. new_conversation → Agrega conversación a la lista
// Ver línea ~1122: this.addConversationToList(data)
//
// 3. notification → Muestra toast de notificación
// Ver línea ~1119: this.showNotificationToast(notification)
//
// Esto elimina TODO el polling HTTP y reduce la carga en 95%
// Latencia: 3-30s → < 1s
//
// Sin polling = Sin peticiones constantes = Servidor más eficiente 🚀
}
// New: periodic delta poller to fetch only messages newer than last seen timestamp
async pollNewMessages() {
if (!this.currentUserId) return;
// Do not run delta polling when a full load is in progress (avoids races/clearing)
if (this._fullLoadInProgress) {
if (console && console.debug) console.debug('pollNewMessages skipped due to full load in progress');
return;
}
try {
// If we don't yet have a latest message timestamp, fall back to full load
if (!this._latestMessage) {
return await this.loadMessages(this.currentUserId, true, true);
}
const url = `get_user_messages.php?user_id=${this.currentUserId}&since=${encodeURIComponent(this._latestMessage)}&limit=${this.messageLimit}`;
const resp = await this.apiCall(url);
if (!resp || !resp.success || !Array.isArray(resp.data) || resp.data.length === 0) return;
const messages = resp.data;
// Deduplicate and process
const container = document.getElementById('chat-conversations');
const nearBottomNow = container ? ((container.scrollHeight - container.scrollTop - container.clientHeight) < 150) : true;
let appended = 0;
for (const m of messages) {
try {
// skip duplicates
const exists = this.currentMessages && this.currentMessages.some(x => (x.message_id && m.message_id && String(x.message_id) === String(m.message_id)) || (x.id && m.id && Number(x.id) === Number(m.id)));
if (exists) continue;
if (nearBottomNow && !this._userScrolling) {
// append directly and mark for render
this.currentMessages.push(m);
appended++;
} else {
// stage for later (user is reading history)
this._stagedMessages.push(m);
if (this._stagedMessageIds) this._stagedMessageIds.add(m.message_id || m.id || ('local_' + Date.now()));
this._stagedCount = this._stagedMessages.length;
this.showNewMessagesIndicator(this._stagedCount);
}
} catch (e) { console.warn('pollNewMessages process failed for msg', e); }
}
if (appended > 0) {
// render the new appended messages without disrupting playback/scroll; follow only if near bottom
this.renderMessagesIncremental(false, 0, nearBottomNow && !this._userScrolling);
if (nearBottomNow && !this._userScrolling) this.scrollToBottom();
}
// If we received any delta messages, perform an immediate full fetch to sync any server-side batching
if (messages.length > 0) {
if (!this._fullLoadInProgress) {
try {
if (console && console.debug) console.debug('pollNewMessages: delta returned, triggering immediate full reload');
await this.loadMessages(this.currentUserId, true, nearBottomNow && !this._userScrolling);
} catch (e) {
console.warn('Immediate full reload after delta failed', e);
}
} else {
if (console && console.debug) console.debug('Immediate full reload skipped because another full load is in progress');
}
}
// update latest timestamp from the last message
try {
const last = messages[messages.length - 1];
if (last && last.created_at) this._latestMessage = last.created_at;
} catch (e) { /* ignore */ }
} catch (e) {
console.warn('pollNewMessages failed', e);
}
}
// Helper para llamadas a la API desde esta clase
async apiCall(endpoint, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin', // Incluir cookies de sesión
cache: 'no-store' // Evitar caché del navegador
};
// Si se pasa body, asumimos POST (y serializamos)
const finalOptions = { ...defaultOptions, ...options };
if (options.body && typeof options.body === 'object') {
finalOptions.method = 'POST';
finalOptions.body = JSON.stringify(options.body);
}
// Normalizar endpoint: prefijar 'api/' si no es URL completa y no empieza con 'api/'
let url = endpoint;
if (!/^https?:\/\//i.test(url) && !url.startsWith('api/')) {
url = 'api/' + url;
}
const response = await fetch(url, finalOptions);
if (response.status === 401) {
// Sesión expirada: redirigir al login
console.warn('apiCall: unauthorized, redirecting to login');
window.location.href = 'login.php';
return null;
}
const text = await response.text();
if (!response.ok) {
// Si es 401, intentar parsear el JSON para obtener el mensaje real
if (response.status === 401) {
try {
const errorData = JSON.parse(text);
if (errorData.error) {
alert('Sesión expirada: ' + errorData.error + '. Recargando página...');
}
} catch (e) {
alert('Sesión expirada. Recargando página...');
}
// Forzar recarga para restablecer sesión
setTimeout(() => window.location.reload(), 1000);
return null;
}
// Incluir el cuerpo de la respuesta (truncado) para diagnóstico
const snippet = text && text.length ? (text.length > 2000 ? text.substr(0, 2000) + '... (truncated)' : text) : '<no body>';
console.error('apiCall HTTP error', response.status, url, snippet);
throw new Error('HTTP error! status: ' + response.status + ' -- ' + snippet);
}
if (!text || text.trim() === '') return null;
try {
return JSON.parse(text);
} catch (err) {
console.error('apiCall parse error for', url, err);
const snippet = text.length > 1000 ? text.substr(0, 1000) : text;
throw new Error('Invalid JSON response from ' + url + ': ' + err.message + ' -- response snippet: ' + snippet);
}
}
async loadConversations(page = 1, append = false) {
// 🎯 OPTIMIZACIÓN: Solo cargar del servidor si es la primera vez O si es paginación
// Después SSE se encarga de actualizar automáticamente
if (this._conversationsLoaded && !append) {
console.log('⚡ Conversaciones ya cargadas, SSE se encarga de actualizaciones');
return;
}
if (this.loadingConversations) return;
this.loadingConversations = true;
const loadMoreContainer = document.getElementById('load-more-container');
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) loadMoreBtn.disabled = true;
try {
const url = `api/get_conversations.php?page=${page}&limit=${this.conversationsLimit}&filter=${encodeURIComponent(this.conversationFilter)}`;
const resp = await fetch(url, { credentials: 'same-origin', cache: 'no-store' });
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
}
const data = await resp.json();
console.log('Respuesta get_conversations:', data); // Debug
let items = [];
let hasMore = false;
if (data && data.success && Array.isArray(data.data)) {
items = data.data;
hasMore = !!data.has_more || (page * this.conversationsLimit) < (data.total || 0);
} else if (Array.isArray(data)) {
items = data;
hasMore = items.length === this.conversationsLimit;
} else {
console.error('Error cargando conversaciones: formato de datos inválido', data);
alert('Error cargando conversaciones: ' + (data.error || 'formato de datos inválido'));
this.loadingConversations = false;
if (loadMoreBtn) loadMoreBtn.disabled = false;
return;
}
if (append) {
this.conversations = this.conversations.concat(items);
this._justLoadedMore = true; // evita que renderConversations baje el scroll y dispare otro loadMore
} else {
this.conversations = items;
}
this.hasMoreConversations = hasMore;
this.conversationsPage = page;
// Marcar como cargadas después de la primera carga exitosa
if (!append) {
this._conversationsLoaded = true;
console.log('✅ Primera carga de conversaciones completada, SSE tomará el control');
}
this.renderConversations();
if (loadMoreContainer) {
loadMoreContainer.style.display = this.hasMoreConversations ? 'block' : 'none';
}
} catch (error) {
console.error('Error loading conversations:', error);
alert('Error cargando conversaciones: ' + error.message);
} finally {
this.loadingConversations = false;
if (loadMoreBtn) loadMoreBtn.disabled = false;
}
}
async loadMoreConversations() {
if (!this.hasMoreConversations || this.loadingConversations) return;
await this.loadConversations(this.conversationsPage + 1, true);
}
renderConversations() {
console.log('🎨 renderConversations INICIADO');
console.log('📊 Total conversaciones:', this.conversations?.length || 0);
const container = document.getElementById('conversation-list');
if (!container) {
console.error('❌ No se encontró el elemento #conversation-list en el DOM');
return;
}
console.log('✅ Container encontrado:', container);
console.log('🔍 Filtro activo:', this.conversationFilter);
// Aplicar filtro de no leídos
let conversationsToShow = this.conversations;
if (this.conversationFilter === 'unread') {
conversationsToShow = this.conversations.filter(c => (c.unread_count || 0) > 0);
console.log(`🔍 Filtro 'unread' aplicado: ${conversationsToShow.length} de ${this.conversations.length} conversaciones`);
}
if (conversationsToShow.length === 0) {
const emptyMsg = this.conversationFilter === 'unread' ? 'No hay conversaciones no leídas' : 'No hay conversaciones aún';
container.innerHTML = `
<div class="text-center p-4">
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
<p class="text-muted">${emptyMsg}</p>
</div>
`;
console.log('📭 Lista vacía, mostrando mensaje');
return;
}
console.log('📋 Generando HTML para', conversationsToShow.length, 'conversaciones');
// Ahora el backend devuelve por usuario: last_message, last_time, unread_count y avatar_url
const html = conversationsToShow.map(conv => {
const isActive = conv.user_id == this.currentUserId ? 'active' : '';
const time = this.formatTime(conv.last_time);
// Mostrar miniatura si el último mensaje es multimedia y tiene archivo local
let preview = '';
const hasLocalMedia = conv.last_local_file || conv.last_local_thumb;
const isMediaType = conv.message_type && ['image', 'video', 'audio', 'document'].includes(conv.message_type);
if (hasLocalMedia && isMediaType) {
const thumbSrc = conv.last_local_thumb || conv.last_local_file;
const mediaIcon = {
'image': '🖼️',
'video': '🎥',
'audio': '🎵',
'document': '📎'
}[conv.message_type] || '📎';
if (conv.message_type === 'image' || conv.message_type === 'video') {
preview = `<img src="/${thumbSrc}" alt="media" style="width:40px; height:40px; object-fit:cover; border-radius:4px; margin-right:8px; vertical-align:middle;"> ${mediaIcon} ${conv.last_message || 'Multimedia'}`;
} else {
preview = `${mediaIcon} ${conv.last_message || 'Archivo'}`;
}
} else if (conv.matched_content) {
// Mostrar snippet del mensaje que coincidió en la búsqueda
const snippet = this.truncateText(conv.matched_content, 55);
preview = `<span style="color:#888;font-size:11px;">💬 </span>${escapeHtml(snippet)}`;
} else {
preview = this.truncateText(conv.last_message || 'Sin mensajes', 50);
}
const avatar = conv.avatar_url ? `<img src="${conv.avatar_url}" alt="avatar" class="conversation-avatar-img">` : `<div class="conversation-avatar">${this.getInitials(conv.name)}</div>`;
const unreadBadge = conv.unread_count && conv.unread_count > 0 ? `<span class="badge bg-danger unread-badge">${conv.unread_count}</span>` : '';
const attentionBadge = conv.advisor_requested ? `<span class="badge bg-danger ms-1">¡Atención!</span>` : '';
// different color if has unread and not active
const unreadClass = (conv.unread_count && conv.unread_count > 0 && !isActive) ? 'unread' : '';
const attentionClass = conv.advisor_requested ? ' attention' : '';
const blockedClass = conv.user_status === 'blocked' ? ' user-blocked' : '';
// Escapar caracteres especiales en nombre y teléfono para no romper el onclick
const safeName = (conv.name || '').replace(/\r?\n/g, ' ').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/`/g, '\\`');
const safePhone = (conv.phone_number || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `
<div class="conversation-item ${isActive} ${unreadClass}${attentionClass}${blockedClass}" data-user-id="${conv.user_id}" onclick="chat.openConversation(${conv.user_id}, '${safeName}', '${safePhone}')">
<div class="conversation-avatar-wrapper">
${avatar}
</div>
<div class="conversation-info">
<div class="conversation-name">${conv.name} ${unreadBadge} ${attentionBadge}</div>
<div class="conversation-preview">
${conv.direction === 'outgoing' ? '✓ ' : ''}${preview}
</div>
</div>
<div class="conversation-time">
${time}
</div>
</div>
`;
}).join('');
console.log('📄 HTML generado, longitud:', html.length, 'caracteres');
// Detectar nuevas notificaciones: comparar unread counts previos (usar todas las conversaciones, no solo las filtradas)
if (!this._prevConversations) this._prevConversations = {};
this.conversations.forEach(c => {
const prev = this._prevConversations[c.user_id] || { unread_count: 0 };
if (c.unread_count > prev.unread_count && c.user_id != this.currentUserId) {
// nueva notificación
this.playNotificationSound();
}
this._prevConversations[c.user_id] = { unread_count: c.unread_count };
});
// Preservar posición visual del scroll del sidebar
try {
const prevScrollTop = container.scrollTop;
const prevScrollHeight = container.scrollHeight;
const wasAtTop = prevScrollTop <= 4;
// Flag: si veníamos de un loadMore (items appended al fondo) NO compensar
const wasLoadingMore = !!this._justLoadedMore;
this._justLoadedMore = false;
container.innerHTML = html;
void container.offsetHeight; // forzar repaint
if (wasAtTop) {
container.scrollTop = 0;
} else if (wasLoadingMore) {
// Items se agregaron al FONDO — mantener posición exacta sin compensar
// (compensar causaría que el scroll baje y disparara otro loadMore)
container.scrollTop = prevScrollTop;
} else {
// Re-render normal (SSE update): compensar cambio de altura
const scrollDelta = container.scrollHeight - prevScrollHeight;
container.scrollTop = Math.max(0, prevScrollTop + scrollDelta);
}
} catch (e) {
console.warn('renderConversations: scroll preservation failed', e);
container.innerHTML = html;
void container.offsetHeight;
}
}
getInitials(name) {
if (!name) return '?';
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now - date) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
} else if (diffInHours < 48) {
return 'Ayer';
} else {
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit'
});
}
}
truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
// Cache ligero del estado del usuario (in_service, advisor_requested, on_hold)
async getUserState(userId, force = false) {
if (!userId) return null;
this._userStateCache = this._userStateCache || {};
const now = Date.now();
const cached = this._userStateCache[userId];
const TTL = 15000; // 15 segundos
if (!force && cached && (now - cached.ts) < TTL) {
return cached.state;
}
try {
const resp = await this.apiCall(`get_conversation_detail.php?user_id=${userId}`);
if (resp && resp.success && resp.user) {
const s = {
in_service: !!resp.user.in_service,
advisor_requested: !!resp.user.advisor_requested,
on_hold: !!resp.user.on_hold
};
this._userStateCache[userId] = { state: s, ts: Date.now() };
return s;
}
} catch (e) {
console.warn('getUserState failed for', userId, e);
}
// fallback: keep previous cached or return null
if (cached) return cached.state;
this._userStateCache[userId] = { state: null, ts: Date.now() };
return null;
}
getConversationPhone(userId) {
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) return conv.phone_number || conv.phone || conv.user_phone || null;
const el = document.getElementById('chat-phone');
return el ? el.textContent.trim() : null;
}
playNotificationSound() {
try {
// Small beep using Web Audio API (no external file needed)
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'sine';
o.frequency.value = 880;
o.connect(g);
g.connect(ctx.destination);
g.gain.value = 0.05;
o.start();
setTimeout(() => { o.stop(); ctx.close(); }, 180);
} catch (e) {
console.warn('Notification sound failed', e);
}
}
async openConversation(userId, userName, phoneNumber) {
this.currentUserId = userId;
// Clear any staged messages from a previous conversation and hide indicator
try { if (this._stagedMessageIds) { this._stagedMessageIds.clear(); this._stagedCount = 0; this.hideNewMessagesIndicator(); } } catch(e) {}
// Actualizar inmediatamente el contador de no leídos a 0 en la lista local
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) {
conv.unread_count = 0;
// Re-renderizar la lista para reflejar el cambio inmediatamente
this.renderConversations();
}
// Actualizar UI
document.getElementById('no-conversation').style.display = 'none';
document.getElementById('chat-area').style.display = 'flex';
// Actualizar header
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
nameEl.textContent = userName;
document.getElementById('chat-phone').textContent = phoneNumber;
document.getElementById('chat-avatar').textContent = this.getInitials(userName);
// Marcar conversación como activa
document.querySelectorAll('.conversation-item').forEach(item => {
item.classList.remove('active');
});
const activeItem = document.querySelector(`[data-user-id="${userId}"]`);
if (activeItem) {
activeItem.classList.add('active');
// Scroll al ítem activo solo al abrirlo (no en cada re-render)
activeItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
// Actualizar estado del toggle del bot según datos de la conversación (ya obtuvimos conv arriba)
if (conv) {
const btn = document.getElementById('bot-toggle');
const holdIndicator = document.getElementById('hold-indicator');
const releaseBtn = document.getElementById('release-hold-btn');
const markUnreadBtn = document.getElementById('mark-unread-btn');
// On mobile, hide the sidebar when opening a conversation so chat occupies full screen
const sidebar = document.querySelector('.chat-sidebar');
if (sidebar && window.innerWidth <= 768) {
sidebar.classList.add('mobile-hidden');
}
if (markUnreadBtn) {
markUnreadBtn.style.display = 'inline-block';
markUnreadBtn.onclick = async () => {
try {
const resp = await fetch('api/mark_conversation_unread.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
cache: 'no-store'
});
if (resp.status === 401) {
alert('Sesión expirada. Por favor inicie sesión de nuevo.');
return;
}
const json = await resp.json();
if (json && json.success) {
// Actualizar la conversación local para mostrar badge de no leído
const conv = this.conversations.find(c => String(c.user_id) === String(userId));
if (conv) {
conv.unread_count = Math.max(1, conv.unread_count || 1);
}
// Refrescar la lista visual
this.renderConversations();
console.log('✅ Conversación marcada como no leída');
} else {
console.error('Error marcando conversación como no leída:', json);
alert('Error marcando conversación como no leída');
}
} catch (e) {
console.error('Error marking conversation unread', e);
alert('Error marcando conversación como no leída');
}
};
}
if (holdIndicator) {
if (conv.on_hold) {
holdIndicator.textContent = 'EN ESPERA';
holdIndicator.style.color = '#b85';
holdIndicator.style.display = 'inline';
} else if (conv.advisor_requested) {
holdIndicator.textContent = 'SOLICITUD PENDIENTE';
holdIndicator.style.color = '#f39c12';
holdIndicator.style.display = 'inline';
} else {
holdIndicator.style.display = 'none';
}
} else {
console.warn('holdIndicator element not found in DOM');
}
// Badge T&C pendiente
const termsBadge = document.getElementById('terms-pending-badge');
if (termsBadge) {
termsBadge.style.display = conv.terms_pending ? 'inline' : 'none';
}
if (releaseBtn) {
// show button only when conversation is explicitly 'on_hold' (do NOT show during advisor request)
releaseBtn.style.display = (conv.on_hold) ? 'inline-block' : 'none';
releaseBtn.onclick = async () => {
try {
const resp = await fetch('api/release_hold.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId }),
cache: 'no-store'
});
if (resp.status === 401) {
alert('Sesión expirada. Por favor inicie sesión de nuevo.');
return;
}
const json = await resp.json();
if (json && json.success) {
conv.on_hold = false;
conv.advisor_requested = 0;
if (holdIndicator) {
holdIndicator.style.display = 'none';
} else {
console.warn('holdIndicator missing when releasing hold');
}
releaseBtn.style.display = 'none';
// show bot toggle again
if (btn) btn.style.display = 'inline-block';
await this.loadConversations();
} else {
alert('Error al liberar la espera');
}
} catch (e) {
console.error('Error releasing hold', e);
alert('Error liberando espera');
}
};
// Auxiliary attend button is hidden: we use the main Atender/Finalizar control and show status in-chat
try {
const attendToggleBtn = document.getElementById('attend-toggle-btn');
if (attendToggleBtn) {
attendToggleBtn.style.display = 'none';
attendToggleBtn.onclick = null;
}
if (conv && conv.advisor_requested && !conv.in_service) {
try { this.showStatusMessageInChat(userId, 'Solicitud de asesor pendiente'); } catch(e) { /* ignore */ }
}
} catch (e) { console.warn('ensure attend button visibility failed', e); }
// Render attend controls (single toggle + status) — compatible con comportamiento de chat_window.php
const attendToggleBtn = document.getElementById('attend-toggle-btn');
const attendStatus = document.getElementById('attend-status');
const renderAttendControls = () => {
if (!attendToggleBtn || !attendStatus) return;
// Hide auxiliary attend button to avoid having two 'Finalizar' controls
attendToggleBtn.style.display = 'none';
attendToggleBtn.onclick = null;
attendStatus.textContent = '';
// refresh conv reference
const c = this.conversations.find(x => x.user_id === userId) || conv;
// Update only the in-chat status message based on server/local state
this.getUserState(userId).then(serverState => {
const s = serverState || { in_service: c.in_service, advisor_requested: c.advisor_requested };
if (s.in_service) {
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) {}
} else if (s.advisor_requested || c.advisor_requested) {
try { this.showStatusMessageInChat(userId, 'Solicitud de asesor pendiente'); } catch(e) {}
}
}).catch(e => {
if (c.in_service) {
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) {}
} else if (c.advisor_requested) {
try { this.showStatusMessageInChat(userId, 'Solicitud de asesor pendiente'); } catch(e) {}
}
});
};
const toggleAttend = async () => {
try {
const c = this.conversations.find(x => x.user_id === userId) || conv;
// Preflight: obtener estado canonico del servidor con cache corta
const serverState = await this.getUserState(userId, true);
const currentlyInService = serverState ? !!serverState.in_service : !!c.in_service;
if (currentlyInService) {
if (!confirm('¿Confirmas finalizar la atención?')) return;
const resp = await this.apiCall('finish_attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Finalizada la atención', 'success');
c.in_service = false;
c.advisor_requested = 0;
// hide hold indicator
if (holdIndicator) holdIndicator.style.display = 'none';
// release_hold is performed by finish_attend.php; avoid extra client-side call to prevent duplicate notifications.
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: false, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error finalizando');
}
} else {
if (!confirm('¿Confirmas tomar la atención de esta conversación?')) return;
const resp = await this.apiCall('attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Atención iniciada', 'success');
c.in_service = true;
c.advisor_requested = 0;
if (holdIndicator) {
holdIndicator.textContent = 'EN SERVICIO';
holdIndicator.style.color = '#28a745';
holdIndicator.style.display = 'inline';
}
// hide bot toggle while in service
if (btn) btn.style.display = 'none';
// Add status message in chat to inform the user
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) { console.warn('showStatusMessageInChat failed', e); }
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: true, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error iniciando atención');
}
}
} catch (err) {
console.error('Error toggling attend', err);
showAlert('Error al cambiar estado de atención: ' + (err.message || err), 'danger');
} finally {
// re-render controls after any operation
renderAttendControls();
}
};
if (attendToggleBtn) {
// Auxiliary button intentionally disabled; main control handles attend/finish
attendToggleBtn.style.display = 'none';
attendToggleBtn.onclick = null;
}
// inicializar estado
renderAttendControls();
}
if (btn) {
// Usar este botón como control único de Atender / Finalizar
btn.textContent = conv.in_service ? 'Finalizar' : 'Atender';
btn.title = conv.in_service ? 'Finalizar atención' : 'Atender';
// Estilos según estado
btn.classList.toggle('btn-light', !!conv.in_service);
btn.classList.toggle('btn-outline-light', !conv.in_service);
// Mostrar el botón por defecto (se oculta en casos puntuales de on_hold si se desea)
btn.style.display = 'inline-block';
btn.disabled = false;
btn.onclick = async () => {
try {
btn.disabled = true;
// Si ya está en servicio -> finalizar
if (conv.in_service) {
if (!confirm('¿Confirmas finalizar la atención?')) { btn.disabled = false; return; }
const resp = await this.apiCall('finish_attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Finalizada la atención', 'success');
conv.in_service = false;
conv.advisor_requested = 0;
conv.bot_enabled = true;
if (holdIndicator) holdIndicator.style.display = 'none';
// release_hold is performed by finish_attend.php; avoid extra client-side call to prevent duplicate notifications.
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: false, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error finalizando');
}
} else {
// Tomar la atención
if (!confirm('¿Confirmas tomar la atención de esta conversación?')) { btn.disabled = false; return; }
const resp = await this.apiCall('attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Atención iniciada', 'success');
conv.in_service = true;
conv.advisor_requested = 0;
conv.bot_enabled = false;
if (holdIndicator) {
holdIndicator.textContent = 'EN SERVICIO';
holdIndicator.style.color = '#28a745';
holdIndicator.style.display = 'inline';
}
// Add status message in chat to inform the user
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) { console.warn('showStatusMessageInChat failed', e); }
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: true, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error iniciando atención');
}
}
} catch (err) {
console.error('Error toggling attend via main button', err);
showAlert('Error al cambiar estado de atención: ' + (err.message || err), 'danger');
} finally {
btn.disabled = false;
// Actualizar label y clases
btn.textContent = conv.in_service ? 'Finalizar' : 'Atender';
btn.title = conv.in_service ? 'Finalizar atención' : 'Atender';
btn.classList.toggle('btn-light', !!conv.in_service);
btn.classList.toggle('btn-outline-light', !conv.in_service);
// Re-render controls auxiliares
try { renderAttendControls(); } catch(e) { /* ignore */ }
}
};
}
// ── Botón Bloquear / Desbloquear ────────────────────────────
const blockBtn = document.getElementById('block-user-btn');
const blockedIndicator = document.getElementById('blocked-indicator');
if (blockBtn) {
const isBlocked = conv.user_status === 'blocked';
// Actualizar apariencia
blockBtn.classList.toggle('is-blocked', isBlocked);
blockBtn.title = isBlocked ? 'Desbloquear usuario' : 'Bloquear usuario';
blockBtn.innerHTML = isBlocked
? '<i class="fas fa-lock-open"></i>'
: '<i class="fas fa-ban"></i>';
if (blockedIndicator) {
blockedIndicator.style.display = isBlocked ? 'inline' : 'none';
}
blockBtn.onclick = async () => {
const currentlyBlocked = conv.user_status === 'blocked';
const action = currentlyBlocked ? 'desbloquear' : 'bloquear';
if (!confirm(`¿Confirmas ${action} a este usuario?\n${currentlyBlocked ? 'Podrá volver a interactuar con el bot.' : 'El bot ignorará sus mensajes.'}`)) return;
blockBtn.disabled = true;
try {
const resp = await fetch('api/block_user.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, blocked: !currentlyBlocked }),
cache: 'no-store'
});
if (resp.status === 401) { alert('Sesión expirada.'); return; }
const json = await resp.json();
if (json && json.success) {
conv.user_status = json.status;
const nowBlocked = json.blocked;
blockBtn.classList.toggle('is-blocked', nowBlocked);
blockBtn.title = nowBlocked ? 'Desbloquear usuario' : 'Bloquear usuario';
blockBtn.innerHTML = nowBlocked
? '<i class="fas fa-lock-open"></i>'
: '<i class="fas fa-ban"></i>';
if (blockedIndicator) {
blockedIndicator.style.display = nowBlocked ? 'inline' : 'none';
}
// Actualizar ítem en sidebar
const sideItem = document.querySelector(`.conversation-item[data-user-id="${userId}"]`);
if (sideItem) sideItem.classList.toggle('user-blocked', nowBlocked);
showAlert(nowBlocked ? '🚫 Usuario bloqueado' : '✅ Usuario desbloqueado', nowBlocked ? 'warning' : 'success');
} else {
alert('Error: ' + (json && json.error ? json.error : 'Error desconocido'));
}
} catch (e) {
console.error('Error toggling block', e);
alert('Error al cambiar estado de bloqueo');
} finally {
blockBtn.disabled = false;
}
};
}
}
// Cargar mensajes (con paginación)
// Scroll inmediato al fondo para que el usuario vea el final mientras cargamos el bloque completo
try {
const immedContainer = document.getElementById('chat-conversations');
if (immedContainer) {
immedContainer.scrollTop = immedContainer.scrollHeight;
// clear any pending reloads and temporarily suspend auto-refresh to avoid races
this._pendingReloadAfterScroll = false;
this._pendingReloadAfterMedia = false;
this._suspendAutoRefresh = true;
if (console && console.debug) console.debug('openConversation: scrolled to bottom and suspended auto-refresh prior to full load');
}
} catch(e) { /* ignore */ }
await this.loadMessages(userId, true, true);
// Resume auto-refresh after the load
try { this._suspendAutoRefresh = false; } catch(e) {}
// Añadir listener para scroll arriba (cargar más historial)
const chatContainer = document.getElementById('chat-conversations');
if (chatContainer) {
if (!chatContainer._infiniteScrollAdded) {
chatContainer.addEventListener('scroll', async () => {
// Mark that the user is actively scrolling/reading so we don't yank the viewport to bottom
try {
this._userScrolling = true;
if (this._userScrollTimer) clearTimeout(this._userScrollTimer);
this._userScrollTimer = setTimeout(() => {
this._userScrolling = false;
this._userScrollTimer = null;
if (this._pendingReloadAfterScroll && this.currentUserId) {
this._pendingReloadAfterScroll = false;
try { this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('reload after scroll failed', e)); } catch(e) { console.warn('reload after scroll schedule failed', e); }
}
}, 1500);
} catch(e) { /* ignore */ }
if (chatContainer.scrollTop <= 60 && this.hasMoreMessages && !this.loadingMessages && this.currentUserId == userId) {
// User scrolled to top: trigger a full reload (initial=true) but don't jump to bottom (follow=false)
try { if (console && console.debug) console.debug('User scrolled to top - triggering full load (follow=false)'); } catch(e) {}
await this.loadMessages(userId, true, false);
}
// If the user scrolled near the bottom and there are staged messages, apply them
try {
const nearBottomNow = (chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 150;
if (nearBottomNow && this._stagedMessages && this._stagedMessages.length) {
this.applyStagedMessages();
}
} catch(e) { /* ignore */ }
});
chatContainer._infiniteScrollAdded = true;
}
// Media playback listeners: suspend auto-refresh while media is playing and resume when it stops
if (!chatContainer._mediaListenersAdded) {
const onMediaPlay = (ev) => {
try {
this._suspendAutoRefresh = true;
console.debug('Media play detected, suspending periodic reload');
} catch (e) { /* ignore */ }
};
const onMediaEnd = async (ev) => {
try {
this._suspendAutoRefresh = false;
console.debug('Media paused/ended, resuming periodic reload');
if (this._pendingReloadAfterMedia && this.currentUserId) {
this._pendingReloadAfterMedia = false;
await this.loadMessages(this.currentUserId, true, false);
}
} catch (e) { console.warn('media end handler failed', e); }
};
chatContainer.addEventListener('play', onMediaPlay, true);
chatContainer.addEventListener('pause', onMediaEnd, true);
chatContainer.addEventListener('ended', onMediaEnd, true);
chatContainer._mediaListenersAdded = true;
}
}
// Cargar respuestas rápidas y plantillas
await this.loadQuickReplies();
// templates loaded into select by loadQuickReplies
// show template container only if templates exist
const tplContainer = document.getElementById('templateSelectContainer');
const tplSelect = document.getElementById('templateSelect');
const typeSelect = document.getElementById('message-type');
if (tplContainer && tplSelect && tplSelect.children.length) {
// If message-type selector exists, show only when 'template' is selected. Otherwise show (legacy/simple UI).
const showTpl = typeSelect ? (typeSelect.value === 'template') : true;
tplContainer.style.display = showTpl ? 'inline-block' : 'none';
}
// Initialize recording handlers so the mic works even before opening a conversation
if (typeof this.initRecordingHandlers === 'function') {
try { this.initRecordingHandlers(); } catch(e) { console.warn('initRecordingHandlers() failed', e); }
} else {
// fallback: define minimal state
this._mediaRecorder = null;
this._recordingInterval = null;
this._recordingStart = null;
}
}
async loadconversations(userId, showLoading = true) {
// Compatibilidad: ahora delegamos en loadMessages
if (showLoading) {
document.getElementById('chat-conversations').innerHTML = `
<div class="loading">
<i class="fas fa-spinner fa-spin"></i> Cargando mensajes...
</div>
`;
}
try {
await this.loadMessages(userId, true);
} catch (error) {
console.error('Error loading conversations via loadMessages:', error);
document.getElementById('chat-conversations').innerHTML = `
<div class="text-center p-4">
<i class="fas fa-exclamation-triangle text-warning"></i>
<p>Error al cargar los mensajes</p>
</div>
`;
}
}
/**
* Cargar mensajes con paginación. Si initial=true, carga el bloque más reciente; si initial=false, carga mensajes anteriores (before=this.earliestMessage).
*/
async loadMessages(userId, initial = false, follow = true) {
if (this.loadingMessages) return;
this.loadingMessages = true;
// Mark a full load is in progress to avoid race with delta polling
this._fullLoadInProgress = true;
const container = document.getElementById('chat-conversations');
// Guardar posición de scroll y determinar si el usuario estaba en el fondo
let prevScrollHeight = container ? container.scrollHeight : 0;
let prevScrollTop = container ? container.scrollTop : 0;
const wasAtBottom = container ? ((container.scrollHeight - container.scrollTop - container.clientHeight) < 150) : true;
// indicador top cuando cargamos anteriores
let topLoader = null;
if (!initial && container) {
container.insertAdjacentHTML('afterbegin', `<div class="loading loading-top" style="text-align:center; padding:8px; font-size:12px;">Cargando mensajes anteriores...</div>`);
topLoader = container.querySelector('.loading-top');
}
try {
// Not sending 'before' param due to unstable server behavior in some cases.
// The server-side pagination/fallback and client-side dedupe should handle continuity.
// If we prefetched data (e.g. from notification click), use it to avoid double fetch
let data = null;
if (initial && this._preloadedMessages && this._preloadedMessages.userId == userId && this._preloadedMessages.resp) {
data = this._preloadedMessages.resp;
// clear preloaded cache
this._preloadedMessages = null;
if (console && console.debug) console.debug('Using preloaded messages for user', userId);
} else {
let url = `get_user_messages.php?user_id=${userId}&limit=${this.messageLimit}`;
data = await this.apiCall(url);
}
if (!data) throw new Error('No autorizado o error en la petición');
let messages = [];
let hasMore = false;
let earliest = null;
let earliestId = null; // local var for earliest message id
if (data && data.success && Array.isArray(data.data)) {
messages = data.data;
// Filtrar mensajes de tipo 'reaction' para que no aparezcan como elementos en la conversación
messages = messages.filter(m => !(m && m.message_type === 'reaction'));
hasMore = !!data.has_more;
earliest = data.earliest || (messages[0] && messages[0].created_at) || null;
earliestId = data.earliest_id || (messages[0] && messages[0].id) || null;
} else if (Array.isArray(data)) {
messages = data;
messages = messages.filter(m => !(m && m.message_type === 'reaction'));
}
// Filtrar mensajes vacíos (sin texto y sin media) para evitar que aparezcan placeholders "[Mensaje vacío]"
if (Array.isArray(messages) && messages.length > 0) {
const beforeCount = messages.length;
messages = messages.filter(m => {
const content = (typeof m.content !== 'undefined' && m.content !== null) ? String(m.content).trim() : '';
const hasContent = content.length > 0;
const hasMedia = !!(m.local_thumb || m.local_file || m.media_url_external || m.media_url);
const nonText = m.message_type && m.message_type !== 'text';
if (!hasContent && !hasMedia && !nonText) {
// Drop purely empty text message
if (console && console.warn) console.warn('Dropping empty message in pagination:', m && (m.id || m.message_id), m && m.created_at);
return false;
}
return true;
});
const dropped = beforeCount - messages.length;
if (dropped > 0) {
// Indicar de forma no intrusiva en caso de debug o visibilidad limitada
if (typeof showAlert === 'function') {
showAlert(dropped + ' mensajes vacíos omitidos', 'warning');
} else {
if (console && console.info) console.info('Omitidos ' + dropped + ' mensajes vacíos durante paginación');
}
}
}
// Guard: cuando paginamos (initial=false) y la respuesta no trae mensajes,
// no reemplazar ni limpiar la conversación actual; solo indicar que no hay más.
if (!initial && Array.isArray(messages) && messages.length === 0) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } catch(e) {}
// Asegurar que no quedemos en estado de loading
this.loadingMessages = false;
this.hasMoreMessages = false;
// show hint and preserve current view
this.showNoMoreMessagesHint();
// preserve current view and stop further processing
return;
}
// Si es carga inicial y el servidor devolvió vacío, pero ya teníamos mensajes, preservarlos.
if (initial && Array.isArray(messages) && messages.length === 0 && Array.isArray(this.currentMessages) && this.currentMessages.length > 0) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } catch(e) {}
// Ensure container visibility is restored if it was hidden during render
try { if (container && container.dataset && container.dataset.renderHidden) { container.style.visibility = 'visible'; delete container.dataset.renderHidden; } } catch(e) {}
this.loadingMessages = false;
if (typeof showAlert === 'function') showAlert('No se pudieron recargar mensajes; manteniendo historial actual', 'warning');
console.warn('Initial load returned empty but existing view contains messages - preserving current conversation');
return;
}
if (initial) {
// Reemplazar sólo en carga inicial válida (puede estar vacía si no hay nada en DB)
this.currentMessages = messages;
} else {
// Prepend sólo si hay mensajes nuevos
if (Array.isArray(messages) && messages.length > 0) {
// Evitar introducir duplicados que ya existen en la vista actual
try {
const existingKeys = new Set((this.currentMessages || []).map(m => this.messageKey(m)));
const beforeLen = messages.length;
messages = messages.filter(m => {
const k = this.messageKey(m);
const seen = existingKeys.has(k);
if (!seen) existingKeys.add(k); // mark as seen to avoid duplicates within the batch
else {
if (console && console.debug) console.debug('Skipping duplicate message from server (already in view)', m && (m.id || m.message_id), m && m.created_at);
}
return !seen;
});
const dropped = beforeLen - messages.length;
if (dropped > 0) {
if (console && console.info) console.info(`Omitted ${dropped} messages because they already exist in view`);
}
} catch (e) { console.warn('duplicate filtering failed', e); }
// Prepend mensajes antiguos
this.currentMessages = messages.concat(this.currentMessages || []);
} else {
// No hay mensajes nuevos para añadir; nada que hacer
}
}
// Actualizar paginación (earliest timestamp y id)
if (earliest) this.earliestMessage = earliest;
if (typeof earliestId !== 'undefined' && earliestId !== null) this.earliestMessageId = earliestId;
// Update last/latest message timestamp so we can poll deltas later
try {
if (Array.isArray(this.currentMessages) && this.currentMessages.length > 0) {
const last = this.currentMessages[this.currentMessages.length - 1];
if (last && last.created_at) this._latestMessage = last.created_at;
}
} catch(e) { /* ignore */ }
// Improved dedupe: pick the most "readable" message when duplicates exist
try {
if (!this.currentMessages || this.currentMessages.length === 0) {
// Nothing to dedupe
} else {
const best = new Map(); // key => { index, score }
const toRemove = [];
const scoreFor = (m) => {
let s = 0;
if (!m) return s;
// Prefer explicit media type messages
if (m.message_type && m.message_type !== 'text') s += 50;
// Prefer messages with thumbnails or local files
if (m.local_thumb || m.local_file || m.media_url_external || m.media_url) s += 30;
// Prefer human text content (avoid raw JSON blobs)
if (m.content) {
const c = String(m.content).trim();
const looksJson = c.startsWith('{') && c.endsWith('}');
if (looksJson) {
s -= 20; // penalize raw JSON-looking content
} else {
s += 10;
if (c.length < 200) s += 5;
}
}
if (m.message_id) s += 3;
if (m.reply_to_message_id) s += 2;
return s;
};
for (let i = this.currentMessages.length - 1; i >= 0; i--) {
const m = this.currentMessages[i];
if (!m) continue;
const mid = m.message_id || m.id || null;
let key = null;
if (mid) key = String(mid);
else {
const partMedia = m.media_url || m.media_url_external || m.local_file || m.local_thumb || m.content || '';
const ts = m.created_at ? String(Math.floor(new Date(m.created_at).getTime() / 1000)) : '';
key = `${m.direction||'?'}|${m.message_type||m.media_type||'text'}|${partMedia}|${ts}`;
}
const s = scoreFor(m);
if (!best.has(key)) {
best.set(key, { index: i, score: s });
} else {
const prev = best.get(key);
if (s > prev.score) {
// keep current, remove previous
toRemove.push(prev.index);
best.set(key, { index: i, score: s });
} else {
// remove current
toRemove.push(i);
}
}
}
if (toRemove.length) {
// dedupe unique indices
const uniq = Array.from(new Set(toRemove)).sort((a,b) => a - b);
// remove from highest index down
for (let j = uniq.length - 1; j >= 0; j--) {
const idx = uniq[j];
const removed = this.currentMessages.splice(idx, 1)[0];
console.debug('Dedupe removed message at index', idx, 'removed=', removed && (removed.message_id || removed.id || removed.content || removed.media_url) );
}
}
}
} catch (e) {
console.warn('Dedupe failed', e);
}
// Actualizar estado de paginación
this.hasMoreMessages = hasMore;
// Mostrar o remover el hint "No hay más mensajes anteriores" según corresponda
if (this.hasMoreMessages) {
this.removeNoMoreMessagesHint();
} else {
this.showNoMoreMessagesHint();
}
if (earliest) this.earliestMessage = earliest;
// Renderizar incrementalmente para evitar parpadeos y preservar reproducción de media
// Si cargamos paginación (initial=false), pasar la cantidad de mensajes recién obtenidos para insertarlos al inicio
const newlyFetched = (!initial && Array.isArray(messages)) ? messages.length : 0;
if (this.currentMessages && this.currentMessages.length > 0) {
this.renderMessagesIncremental(initial, newlyFetched, follow);
} else if (initial) {
// Si es carga inicial y no tenemos mensajes, mostrar una vista vacía
const container = document.getElementById('chat-conversations');
if (container) container.innerHTML = `<div class="no-conversation"><i class="fab fa-whatsapp"></i><h4>No hay mensajes</h4><p>Envía un mensaje para iniciar la conversación.</p></div>`;
}
// Replace media rendering uses window.renderMediaMessage inside element creation/update
// Full load finished - unset flag so polling can resume
this._fullLoadInProgress = false;
if (initial) {
// Scroll to bottom SOLO si: (1) follow fue solicitado Y (2) el usuario estaba en el fondo O (3) es el primer mensaje
const shouldScrollToBottom = follow && (wasAtBottom || this.currentMessages.length === messages.length);
if (shouldScrollToBottom) {
this.scrollToBottom();
} else {
// Preservar la posición relativa del scroll
if (container && prevScrollHeight > 0) {
const scrollPercentage = prevScrollTop / prevScrollHeight;
container.scrollTop = container.scrollHeight * scrollPercentage;
}
}
// Re-show the container (we hid it before rendering to avoid jump-to-top)
try {
if (container && container.dataset && container.dataset.renderHidden) {
container.style.visibility = 'visible';
delete container.dataset.renderHidden;
}
} catch(e) { /* ignore */ }
} else if (container) {
// Mantener posición: desplazar por la diferencia de heights
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - prevScrollHeight + prevScrollTop;
}
// remover loader top si existía
if (topLoader && topLoader.parentNode) topLoader.remove();
// Marcar como leídos (comportamiento previo: marcar todos los entrantes como leídos al abrir)
if (initial) {
try {
await this.apiCall('mark_conversation_read.php', { method: 'POST', body: { user_id: userId } });
// Recargar lista de conversaciones para refrescar contadores
await this.loadConversations();
} catch (e) {
console.error('Error marking conversation as read', e);
}
}
} catch (error) {
console.error('Error en loadMessages:', error);
// Si la carga es paginada (no "initial"), preservamos la vista actual en vez de borrar el DOM.
if (!initial) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } catch (e) {}
this.hasMoreMessages = false;
// Mostrar hint visual
this.showNoMoreMessagesHint();
// Notificar de forma no intrusiva
if (typeof showAlert === 'function') {
showAlert('No se pudieron cargar mensajes anteriores: ' + (error.message || error), 'warning');
} else {
console.warn('No se pudieron cargar mensajes anteriores:', error);
}
} else {
// En cargas iniciales mostramos el mensaje de error y botón de reintento (comportamiento previo)
if (container) {
// Make sure it's visible again
try { if (container && container.dataset && container.dataset.renderHidden) { container.style.visibility = 'visible'; delete container.dataset.renderHidden; } } catch(e) {}
container.innerHTML = `
<div class="text-center p-4">
<i class="fas fa-exclamation-triangle text-warning fa-2x"></i>
<p class="mt-3">Error al cargar los mensajes: ${error.message || error}</p>
<button class="btn btn-sm btn-primary" id="retry-load-messages">Reintentar</button>
</div>
`;
const retryBtn = document.getElementById('retry-load-messages');
if (retryBtn) retryBtn.addEventListener('click', async () => { await this.loadMessages(userId, true); });
}
}
} finally {
this.loadingMessages = false;
// Ensure the full-load-in-progress flag is cleared even on errors/early returns
try { this._fullLoadInProgress = false; } catch(e) { /* noop */ }
if (console && console.debug) console.debug('loadMessages completed, _fullLoadInProgress cleared');
}
}
// Incremental rendering to avoid DOM replacement that interrupts media playback or scroll
renderMessagesIncremental(initial = false, prependCount = 0, follow = true) {
const container = document.getElementById('chat-conversations');
if (!container) return;
// Safety: remove any empty text-only messages that may have slipped into the model
try {
const before = this.currentMessages ? this.currentMessages.length : 0;
if (Array.isArray(this.currentMessages) && this.currentMessages.length > 0) {
this.currentMessages = this.currentMessages.filter(m => !this.isEmptyMessage(m));
const removed = before - this.currentMessages.length;
if (removed > 0 && console && console.info) console.info('Removed', removed, 'empty messages before render');
}
} catch (e) { console.warn('Failed to trim empty messages before render', e); }
if (initial) {
// Hide the container while we rebuild the DOM to avoid a visible jump to the top
try { container.style.visibility = 'hidden'; container.dataset.renderHidden = '1'; } catch(e) {}
container.innerHTML = '';
}
// Snapshot scroll metrics to preserve user visual position when updating DOM
// prevScrollHeight: total height BEFORE we mutate the DOM
// prevScrollTop: scrollTop BEFORE update (so we can reapply offset)
const prevScrollHeight = container.scrollHeight;
const prevScrollTop = container.scrollTop;
// wasNearBottomBefore: were we following the bottom before update?
const wasNearBottomBefore = (prevScrollHeight - prevScrollTop - container.clientHeight) < 150;
// Map existing nodes
const existing = new Map();
Array.from(container.querySelectorAll('[data-message-id]')).forEach(el => {
existing.set(String(el.dataset.messageId), el);
});
// Compute maximum timestamp among existing DOM messages. This helps detect
// which incoming messages are strictly newer than what the user currently sees.
let maxExistingTs = 0;
existing.forEach(el => {
try {
const d = el.dataset && el.dataset.createdAt ? Date.parse(el.dataset.createdAt) : 0;
if (d && d > maxExistingTs) maxExistingTs = d;
} catch (e) { /* ignore parse errors */ }
});
// helper: format date separators and compare same day
const sameDay = (a,b) => {
try {
const da = new Date(a); const db = new Date(b);
return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
} catch (e) { return false; }
};
const formatDateLabel = (d) => {
try {
const date = new Date(d);
const today = new Date();
const y = new Date(); y.setDate(today.getDate()-1);
if (sameDay(date, today)) return 'Hoy';
if (sameDay(date, y)) return 'Ayer';
// within last 7 days -> weekday name
const diff = Math.floor((today - date) / (1000*60*60*24));
if (diff < 7) return date.toLocaleDateString('es-ES', { weekday: 'long', day: '2-digit', month: '2-digit' });
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch(e) { return ''; }
};
const prependEls = []; // elements to insert at top if we loaded older messages
// calculate near-bottom late to decide scrolling after DOM updates (avoids stale value)
for (let i = 0; i < this.currentMessages.length; i++) {
const msg = this.currentMessages[i];
const mid = String(msg.message_id || msg.id || ('local_' + i));
const existingEl = existing.get(mid);
// Skip truly empty messages (safety net) and remove any existing placeholder nodes
if (this.isEmptyMessage(msg)) {
if (existingEl && existingEl.parentNode) {
existingEl.parentNode.removeChild(existingEl);
existing.delete(mid);
if (console && console.info) console.info('Removed empty DOM message', mid);
}
continue;
}
if (existingEl) {
// update in place
try {
const rp = existingEl.querySelector('.reply-preview');
if (msg.reply_to_message_id) {
const previewSrc = (this.currentMessages.find(m => m.message_id == msg.reply_to_message_id) || {}).content || ('Mensaje ' + msg.reply_to_message_id);
if (rp) rp.textContent = 'En respuesta a: ' + previewSrc.substring(0,140);
else {
const div = document.createElement('div'); div.className = 'reply-preview'; div.textContent = 'En respuesta a: ' + previewSrc.substring(0,140); existingEl.insertBefore(div, existingEl.firstChild);
}
} else if (rp) rp.remove();
const body = existingEl.querySelector('.message-content');
if (body) {
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
const existingMedia = body.querySelector('audio,video,img');
let existingSrc = null;
if (existingMedia) existingSrc = existingMedia.getAttribute('src') || existingMedia.getAttribute('data-src');
const newMediaUrl = mediaPresent ? (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url) : null;
// If media url unchanged, keep existing element (preserve playback)
if (existingMedia && newMediaUrl && existingSrc && String(existingSrc).includes(newMediaUrl)) {
// nothing to change
} else if (existingMedia && newMediaUrl && (!existingSrc || !String(existingSrc).includes(newMediaUrl))) {
// Replace src but preserve playback state for audio/video
try {
const tag = existingMedia.tagName && existingMedia.tagName.toLowerCase();
if (tag === 'audio' || tag === 'video') {
const currentTime = existingMedia.currentTime || 0;
const wasPaused = existingMedia.paused;
existingMedia.src = newMediaUrl;
existingMedia.addEventListener('loadedmetadata', () => {
try {
if (typeof existingMedia.duration === 'number' && !isNaN(existingMedia.duration)) {
existingMedia.currentTime = Math.min(currentTime, existingMedia.duration || currentTime);
}
} catch (e) { /* ignore */ }
try { if (!wasPaused) existingMedia.play().catch(()=>{}); } catch(e){}
}, { once: true });
} else {
// image or other: just replace src
try { existingMedia.src = newMediaUrl; } catch(e) { body.innerHTML = window.renderMediaMessage ? window.renderMediaMessage(msg) : window.escapeHtml(msg.content || '[Mensaje]'); }
}
} catch (e) {
console.warn('preserve media update failed', e);
body.innerHTML = window.renderMediaMessage ? window.renderMediaMessage(msg) : window.escapeHtml(msg.content || '[Mensaje]');
}
} else if (!existingMedia && newMediaUrl) {
// No existing media element, render new media block without touching other parts
try {
body.innerHTML = window.renderMediaMessage ? window.renderMediaMessage(msg) : window.escapeHtml(msg.content || '[Mensaje]');
// If after inserting media/text the body appears empty and there's no media element, replace with placeholder
try {
const hasMediaNode = !!body.querySelector && !!body.querySelector('img,video,audio,source,.message-media');
if ((body.textContent || '').trim() === '' && !hasMediaNode) {
body.innerHTML = '<em>(Mensaje sin texto)</em>';
console.warn('Post-update: media/text produced empty body, set placeholder for', msg && (msg.id || msg.message_id));
}
} catch (e) { /* ignore DOM query errors */ }
} catch(e) { /* ignore */ }
} else if (existingMedia && !newMediaUrl) {
// Media removed in new message: replace body with text/content
try { body.innerHTML = window.escapeHtml(msg.content || '[Mensaje]'); } catch(e) { /* ignore */ }
}
}
const timeEl = existingEl.querySelector('.message-time');
if (timeEl && msg.created_at) {
const full = new Date(msg.created_at).toLocaleString('es-ES');
const short = new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
timeEl.textContent = short;
timeEl.dataset.full = full;
timeEl.dataset.short = short;
// preserve a machine-readable timestamp on the element so we can detect newer messages
try { if (existingEl) existingEl.dataset.createdAt = msg.created_at; } catch(e) {}
if (!timeEl._hasToggleListener) {
timeEl.addEventListener('click', (e) => {
e.stopPropagation();
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
} else {
timeEl.textContent = timeEl.dataset.full;
timeEl.dataset._toggled = '1';
setTimeout(() => {
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
}
}, 4000);
}
});
timeEl._hasToggleListener = true;
}
} else if (timeEl) {
timeEl.textContent = '';
}
const statusEl = existingEl.querySelector('.message-status');
if (statusEl) statusEl.innerHTML = this.getStatusIcon(msg.status);
const reactBadge = existingEl.querySelector('.reaction-badge');
if (msg.reaction_emoji) {
if (reactBadge) reactBadge.textContent = msg.reaction_emoji; else {
const b = document.createElement('div'); b.className = 'reaction-badge'; b.textContent = msg.reaction_emoji; const bubble = existingEl.querySelector('.message-bubble') || existingEl; bubble.insertBefore(b, bubble.querySelector('.message-actions'));
}
} else if (reactBadge) reactBadge.remove();
} catch (e) { console.warn('update message failed', e); }
existing.delete(mid);
} else {
// create new element
try {
// If this message is strictly newer than anything already in the DOM and the user was
// not near the bottom (i.e., reading history), stage it instead of appending to avoid
// moving the user's viewport.
try {
const msgTs = msg.created_at ? Date.parse(msg.created_at) : 0;
const isNewerThanExisting = msgTs && (msgTs > maxExistingTs);
if (isNewerThanExisting && !wasNearBottomBefore && !this._userScrolling) {
this._stagedMessageIds.add(mid);
this._stagedCount = this._stagedMessageIds.size;
this.showNewMessagesIndicator(this._stagedCount);
// skip creation for now; message will be rendered when user clicks the indicator
continue;
}
} catch (e) { /* ignore staging errors */ }
// insert date separator if this message is the first of a new day
const prev = (i>0) ? this.currentMessages[i-1] : null;
if (!prev || !sameDay(prev.created_at, msg.created_at)) {
const sep = document.createElement('div');
sep.className = 'date-sep';
sep.textContent = formatDateLabel(msg.created_at || Date.now());
// For prepended older messages, collect to insert at top
if (prependCount && i < prependCount) prependEls.push(sep);
else container.appendChild(sep);
}
const div = document.createElement('div');
div.className = 'message ' + (msg.direction || 'incoming');
div.dataset.messageId = mid;
div.dataset.createdAt = msg.created_at || '';
// Marcar mensajes nuevos como no leídos si es el último mensaje y viene de SSE
if (this._nextMessageUnread && i === messagesToRender.length - 1 && msg.direction === 'incoming') {
div.classList.add('unread');
div.dataset.isNewUnread = 'true';
}
let replyHtml = '';
if (msg.reply_to_message_id) {
const target = this.currentMessages.find(m => m.message_id == msg.reply_to_message_id || m.id == msg.reply_to_message_id);
const previewText = target ? (target.content || target.message_text || '').substring(0,140) : ('Mensaje ' + msg.reply_to_message_id);
replyHtml = `<div class="reply-preview">En respuesta a: ${window.escapeHtml(previewText)}</div>`;
}
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
// Try to render system-style messages when the message content is a JSON payload
let content = '';
let isSystemJson = false;
try {
const raw = (typeof msg.content === 'string') ? msg.content.trim() : '';
if (!mediaPresent && raw && (raw.startsWith('{') || raw.startsWith('['))) {
try {
const parsed = JSON.parse(raw);
const isSys = parsed && (parsed.type === 'usersentdocuments' || parsed.system === 'usersentdocuments' || parsed.type === 'attention' || parsed.system === 'attention' || parsed.attention);
if (isSys) {
isSystemJson = true;
const title = parsed.title || parsed.message || parsed.text || parsed.summary || 'Documentos recibidos';
const msgText = parsed.message || parsed.text || parsed.summary || '';
const fileName = parsed.file_name || parsed.filename || parsed.file || null;
const fileUrl = parsed.file_url || parsed.url || null;
const fileSize = parsed.file_size || parsed.size || null;
const parts = [];
parts.push(`<div class="message-template system" style="display:flex; gap:8px; align-items:center">`);
parts.push(`<span class="mt-icon">📎</span>`);
parts.push(`<div style="flex:1">`);
parts.push(`<div style="font-weight:700; margin-bottom:6px">${escapeHtml(String(title))}</div>`);
if (msgText) parts.push(`<div style="font-size:13px; color:#444">${escapeHtml(String(msgText))}</div>`);
if (fileName) {
parts.push(`<div style="margin-top:8px; display:flex; gap:8px; align-items:center;">
<i class="${getFileIcon(fileName)}"></i>
<div style="display:flex; flex-direction:column;">
<div class="document-name">${escapeHtml(fileName)}</div>
<div class="document-size text-muted" style="font-size:12px">${fileSize ? this.formatFileSize(Number(fileSize)) : ''}</div>
</div>
</div>`);
}
parts.push(`</div>`);
parts.push(`<div style="margin-left:8px; display:flex; gap:6px; align-items:center">`);
if (fileUrl) {
parts.push(`<a href="${escapeHtml(fileUrl)}" target="_blank" class="btn btn-sm btn-outline-primary">Abrir</a>`);
parts.push(`<button class="btn btn-sm btn-primary download-doc-btn">Descargar</button>`);
}
parts.push(`</div>`);
parts.push(`</div>`);
content = parts.join('');
}
} catch (e) {
// not JSON or parse error — fall back to normal rendering
}
}
} catch (e) { /* ignore */ }
if (!isSystemJson) {
content = mediaPresent
? (window.renderMediaMessage ? window.renderMediaMessage(msg) : (window.formatMessageContent ? window.formatMessageContent(msg.content || '[Mensaje]') : window.escapeHtml(msg.content || '[Mensaje]')))
: (window.formatMessageContent ? window.formatMessageContent(msg.content || '') : window.escapeHtml(msg.content || ''));
// If after formatting the content is empty (spaces or nothing), show a clearer placeholder.
// But allow pure media-only content (images/audio/video) to render instead of being treated as empty.
try {
const tmp = (content || '').replace(/<[^>]*>/g, '').trim(); // strip tags and check
const hasMediaTag = /<(img|audio|video|source|a[^>]*download|div\s+class=["']?message-media)/i.test(content || '');
if (!tmp && !hasMediaTag) {
console.warn('Rendered message content empty, replacing with placeholder', mid, msg.id, msg.created_at);
content = '<em>(Mensaje sin texto)</em>';
}
} catch (e) { /* ignore */ }
} else {
// If it is the system JSON, we may want to highlight it briefly after insertion later
}
const time = msg.created_at ? new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) : '';
const statusIcon = this.getStatusIcon(msg.status);
const reactionHtml = msg.reaction_emoji ? `<div class="reaction-badge">${msg.reaction_emoji}</div>` : '';
div.innerHTML = `
<div class="message-bubble">
${replyHtml}
<div class="message-content">${content}</div>
${reactionHtml}
<div class="message-actions mt-1">
<button class="btn btn-sm btn-link" onclick="chat.promptReply('${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Responder"><i class="fas fa-reply"></i></button>
<button class="btn btn-sm btn-link" onclick="chat.openReactionPicker(event, '${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Reaccionar"><i class="far fa-grin"></i></button>
${(['image','document','video','audio','sticker'].includes(msg.message_type) && msg.direction !== 'outgoing') ? `<button class="btn btn-sm btn-link text-primary" data-msg-id="${msg.id||0}" data-local-file="${msg.local_file||msg.local_thumb||''}" data-conv-id="${chat ? chat.currentConversationId : 0}" onclick="labDomicilio.abrir(+this.dataset.msgId, this.dataset.localFile, +this.dataset.convId)" title="Agendar domicilio"><i class="fas fa-house-medical"></i></button>` : ''}${window._labAddrBtn ? window._labAddrBtn(msg, chat ? chat.currentConversationId : 0) : ''}
</div>
<div class="message-time">${time} ${msg.direction === 'outgoing' ? `<span class="message-status">${statusIcon}</span>` : ''}</div>
</div>
`;
// For prepended older messages, collect to insert at top later
if (prependCount && i < prependCount) {
prependEls.push(div);
} else {
container.appendChild(div);
}
// attach small behavior: allow tapping the time to reveal full date/time briefly
try {
const timeEl = div.querySelector('.message-time');
if (timeEl && msg.created_at) {
const full = new Date(msg.created_at).toLocaleString('es-ES');
const short = new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
timeEl.dataset.full = full;
timeEl.dataset.short = short;
timeEl.addEventListener('click', (e) => {
e.stopPropagation();
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
} else {
timeEl.textContent = timeEl.dataset.full;
timeEl.dataset._toggled = '1';
setTimeout(() => {
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
}
}, 4000);
}
});
}
} catch (e) { /* ignore */ }
// Attach handlers for system-like messages rendered from JSON (view/download buttons + highlight)
try {
const raw = (typeof msg.content === 'string') ? msg.content.trim() : '';
if (raw && (raw.startsWith('{') || raw.startsWith('['))) {
const parsed = JSON.parse(raw);
const isSys = parsed && (parsed.type === 'usersentdocuments' || parsed.system === 'usersentdocuments' || parsed.type === 'attention' || parsed.system === 'attention' || parsed.attention);
if (isSys) {
const bubble = div.querySelector('.message-template.system') || div.querySelector('.message-template');
if (bubble) {
const viewBtn = bubble.querySelector('.view-docs-btn');
const downloadBtn = bubble.querySelector('.download-doc-btn');
const fileUrl = parsed.file_url || parsed.url || null;
const userId = parsed.user_id || parsed.data && parsed.data.user_id || null;
if (downloadBtn) downloadBtn.addEventListener('click', () => {
if (fileUrl) {
const a = document.createElement('a'); a.href = fileUrl; a.download = parsed.file_name || ''; document.body.appendChild(a); a.click(); a.remove();
}
});
// brief visual highlight
bubble.classList.add('system-highlight');
setTimeout(() => bubble.classList.remove('system-highlight'), 4200);
}
}
}
} catch (e) { /* ignore */ }
} catch (e) { console.warn('create message failed', e); }
}
}
// insert prepended elements (keep order)
if (prependEls.length) {
const frag = document.createDocumentFragment();
prependEls.forEach(e => frag.appendChild(e));
container.insertBefore(frag, container.firstChild);
}
// remove any remaining old elements that weren't updated
existing.forEach((el) => el.remove());
// After DOM changes, compute new heights and preserve user's visual position when appropriate
const newScrollHeight = container.scrollHeight;
const scrollDelta = newScrollHeight - prevScrollHeight; // positive when content grew
if (initial) {
if (follow) {
// Initial load and follow requested: show latest messages
this.scrollToBottom();
} else {
// Initial load but DO NOT follow: preserve visual offset like we do for non-initial updates
if (!wasNearBottomBefore && !this._userScrolling) {
const newTop = Math.max(0, prevScrollTop + scrollDelta);
container.scrollTop = newTop;
} else {
const nearBottomNow = (newScrollHeight - container.scrollTop - container.clientHeight) < 150;
if (nearBottomNow && !this._userScrolling) {
this.scrollToBottom();
}
}
}
} else {
// If the user was reading history (not near bottom before) and is not actively scrolling,
// keep the viewport anchored to the same messages by adjusting scrollTop by the delta.
if (!wasNearBottomBefore && !this._userScrolling) {
// Keep the same visual offset (don't jump)
const newTop = Math.max(0, prevScrollTop + scrollDelta);
container.scrollTop = newTop;
} else {
// If we were near bottom and still not actively scrolling, follow new messages
const nearBottomNow = (newScrollHeight - container.scrollTop - container.clientHeight) < 150;
if (nearBottomNow && !this._userScrolling) {
this.scrollToBottom();
}
// Otherwise (user actively scrolling) do nothing and let user's action control view
}
}
}
// Mostrar / quitar hint "No hay más mensajes anteriores"
showNoMoreMessagesHint() {
try {
const container = document.getElementById('chat-conversations');
if (!container) return;
if (container.querySelector('.no-more-messages-hint')) return; // ya existe
const hint = document.createElement('div');
hint.className = 'no-more-messages-hint text-center text-muted';
hint.style.fontSize = '12px';
hint.style.padding = '6px';
hint.textContent = 'No hay más mensajes anteriores';
container.insertBefore(hint, container.firstChild);
} catch (e) { console.warn('showNoMoreMessagesHint failed', e); }
}
removeNoMoreMessagesHint() {
try {
const container = document.getElementById('chat-conversations');
if (!container) return;
const el = container.querySelector('.no-more-messages-hint');
if (el && el.parentNode) el.parentNode.removeChild(el);
} catch (e) { console.warn('removeNoMoreMessagesHint failed', e); }
}
// Helper: generate stable key for a message to detect duplicates
messageKey(m) {
if (!m) return '';
const mid = m.message_id || m.id || '';
if (mid) return String(mid);
const partMedia = m.media_url || m.media_url_external || m.local_file || m.local_thumb || (m.content || '');
const ts = m.created_at ? String(Math.floor(new Date(m.created_at).getTime() / 1000)) : '';
return `${m.direction||'?'}|${m.message_type||m.media_type||'text'}|${partMedia}|${ts}`;
}
// Helper: determine if a message is effectively empty (no text, no media, and is text type)
isEmptyMessage(m) {
if (!m) return true;
const content = (typeof m.content !== 'undefined' && m.content !== null) ? String(m.content).trim() : '';
const hasContent = content.length > 0;
const hasMedia = !!(m.local_thumb || m.local_file || m.media_url_external || m.media_url);
const nonText = m.message_type && m.message_type !== 'text';
return !(hasContent || hasMedia || nonText);
}
// Helper: detect active media playback or recording to avoid interrupting with reloads
isMediaActive() {
// Recording in progress -> active
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') return true;
// Explicit suspend flag
if (this._suspendAutoRefresh) return true;
try {
const container = document.getElementById('chat-conversations');
if (!container) return false;
const mediaEls = container.querySelectorAll('audio,video');
for (const el of mediaEls) {
if (!el.paused && !el.ended) return true;
if (el.readyState > 2 && !el.paused) return true;
}
} catch (e) {
console.warn('isMediaActive: failed to inspect media elements', e);
}
return false;
}
// Initialize recording handlers (so mic works before openConversation) and helper to manage recording state
initRecordingHandlers() {
if (this._recordingHandlersInitialized) return;
this._recordingHandlersInitialized = true;
this._mediaRecorder = null;
this._recordingInterval = null;
this._recordingStart = null;
this.startRecording = async function() {
try {
// Verificar que el navegador soporta MediaDevices
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Tu navegador no soporta grabación de audio');
}
// Suspend periodic reload while recording
this._suspendAutoRefresh = true;
// Intentar obtener acceso al micrófono con diferentes configuraciones
let stream = null;
try {
// Intento 1: Configuración básica sin especificar dispositivo
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
} catch (err1) {
console.warn('Intento 1 falló, probando configuración más simple:', err1.message);
try {
// Intento 2: Configuración minimalista
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err2) {
console.error('Todos los intentos fallaron');
throw err2;
}
}
if (!stream) {
throw new Error('No se pudo obtener acceso al micrófono');
}
this._mediaRecorder = new MediaRecorder(stream);
const chunks = [];
this._mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
this._mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
let file = new File([blob], `record_${Date.now()}.webm`, { type: 'audio/webm' });
// Try convert if FFmpeg available
try {
if (window && window.ensureFFmpeg) {
await window.ensureFFmpeg();
const converted = await window.convertWebmToOgg(file);
file = converted;
}
} catch (convErr) { console.warn('conversion failed', convErr); }
// set as selected file and show preview
this.selectedFile = file;
// Marcar como grabación de voz para enviar como nota de voz (ptt=true)
this._isVoiceRecording = true;
this.showMediaPreview(file);
// restore mic UI to default (not recording)
try {
const micEl2 = document.getElementById('mic-btn');
if (micEl2) {
micEl2.innerHTML = micEl2.dataset._wa_prev || '<i class="fas fa-microphone"></i>';
micEl2.title = 'Grabar audio';
micEl2.classList.remove('recording-cancel');
}
} catch(e) { /* ignore */ }
// Clear recorder reference to indicate stopped
this._mediaRecorder = null;
// Resume auto-refresh and trigger a reload if one was pending
try {
this._suspendAutoRefresh = false;
if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) {
this._pendingReloadAfterMedia = false;
this._pendingReloadAfterScroll = false;
await this.loadMessages(this.currentUserId, true, false);
}
} catch (e) { console.warn('Failed to resume reload after recording', e); }
};
this._mediaRecorder.start();
this._recordingStart = Date.now();
const ind = document.getElementById('recording-indicator'); if (ind) ind.style.display = 'block';
const micEl = document.getElementById('mic-btn'); if (micEl) {
// Show cancel on same button
micEl.dataset._wa_prev = micEl.innerHTML;
micEl.innerHTML = '<i class="fas fa-times"></i>';
micEl.title = 'Cancelar';
micEl.classList.add('recording');
micEl.classList.add('recording-cancel');
}
// Ensure UI shows mic and hides send while recording
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
const update = () => {
if (!this._recordingStart) return;
const elapsed = Math.floor((Date.now() - this._recordingStart) / 1000);
const m = Math.floor(elapsed/60); const s = elapsed % 60;
const rt = document.getElementById('recording-time'); if (rt) rt.textContent = `${m}:${String(s).padStart(2,'0')}`;
};
update();
this._recordingInterval = setInterval(update, 1000);
} catch (err) {
console.error('startRecording error', err.name, err.message);
// Mensajes de error más específicos
let errorMsg = 'No se pudo acceder al micrófono';
if (err.name === 'NotFoundError') {
errorMsg = 'No se encontró ningún micrófono. Conecta un micrófono e intenta de nuevo.';
} else if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMsg = 'Permiso denegado. Permite el acceso al micrófono en la configuración del navegador.';
} else if (err.name === 'NotReadableError') {
errorMsg = 'El micrófono está siendo usado por otra aplicación.';
} else if (err.message) {
errorMsg += ': ' + err.message;
}
alert(errorMsg);
// Limpiar estado
this._suspendAutoRefresh = false;
const ind = document.getElementById('recording-indicator');
if (ind) ind.style.display = 'none';
const micEl = document.getElementById('mic-btn');
if (micEl) micEl.classList.remove('recording', 'recording-cancel');
}
};
this.stopRecording = function() {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop();
}
if (this._recordingInterval) clearInterval(this._recordingInterval);
this._recordingInterval = null;
this._recordingStart = null;
const ind = document.getElementById('recording-indicator'); if (ind) ind.style.display = 'none';
const micEl = document.getElementById('mic-btn'); if (micEl) micEl.classList.remove('recording');
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
// Ensure we resume periodic reloads and trigger pending reload
try {
this._suspendAutoRefresh = false;
if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) {
this._pendingReloadAfterMedia = false;
this._pendingReloadAfterScroll = false;
this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('Reload after stopRecording failed', e));
}
} catch (e) { console.warn('stopRecording resume failed', e); }
};
this.cancelRecording = function() {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop();
}
this._mediaRecorder = null;
this.selectedFile = null;
if (this._recordingInterval) clearInterval(this._recordingInterval);
this._recordingInterval = null;
this._recordingStart = null;
const ind = document.getElementById('recording-indicator'); if (ind) ind.style.display = 'none';
const micEl = document.getElementById('mic-btn'); if (micEl) {
micEl.classList.remove('recording');
micEl.classList.remove('recording-cancel');
micEl.innerHTML = micEl.dataset._wa_prev || '<i class="fas fa-microphone"></i>';
micEl.title = 'Grabar audio';
}
// hide delete button if present
const delBtn = document.getElementById('delete-file-btn'); if (delBtn) delBtn.style.display = 'none';
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
// Resume auto-refresh if it was suspended and trigger pending reload
try {
this._suspendAutoRefresh = false;
if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) {
this._pendingReloadAfterMedia = false;
this._pendingReloadAfterScroll = false;
this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('Reload after cancelRecording failed', e));
}
} catch (e) { console.warn('cancelRecording resume failed', e); }
};
return true;
}
// --- New messages indicator helpers ---
showNewMessagesIndicator(count) {
try {
if (this._applyingStaged) return; // avoid toggling while applying
const container = document.getElementById('chat-area') || document.body;
let el = document.getElementById('new-messages-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'new-messages-indicator';
el.style.position = 'absolute';
el.style.left = '50%';
el.style.transform = 'translateX(-50%)';
el.style.bottom = '84px';
el.style.zIndex = '1500';
el.className = 'btn btn-success';
el.style.padding = '10px 18px';
el.style.borderRadius = '24px';
el.style.boxShadow = '0 6px 20px rgba(37, 211, 102, 0.35)';
el.style.cursor = 'pointer';
el.style.fontWeight = '600';
el.style.fontSize = '14px';
el.innerHTML = '<i class="fas fa-arrow-down me-2"></i>Nuevo mensaje';
el.onclick = async () => {
try {
console.log('🔄 Clic en indicador de nuevos mensajes');
// Mostrar loading en el botón
el.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Cargando...';
el.style.pointerEvents = 'none';
// Limpiar staged messages
this._stagedMessages = [];
if (this._stagedMessageIds) this._stagedMessageIds.clear();
this._stagedCount = 0;
// Suspender auto-refresh para evitar conflictos
this._suspendAutoRefresh = true;
if (this.currentUserId) {
// Reset loading flag para permitir la carga
this.loadingMessages = false;
// Cargar mensajes
await this.loadMessages(this.currentUserId, true, true);
// Scroll al fondo
this.scrollToBottom();
console.log('✅ Mensajes cargados correctamente');
}
// Ocultar indicador
this.hideNewMessagesIndicator();
} catch (e) {
console.error('❌ Error al cargar mensajes:', e);
// En caso de error, intentar ocultar el indicador y restaurar estado
this.hideNewMessagesIndicator();
} finally {
this._suspendAutoRefresh = false;
}
};
// subtle fade-in
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease, transform 220ms ease';
container.appendChild(el);
setTimeout(() => { try { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(-6px)'; } catch(e) {} }, 20);
}
const c = (typeof count === 'number') ? count : (this._stagedMessages ? this._stagedMessages.length : 0);
el.innerHTML = (c && c > 1)
? `<i class="fas fa-arrow-down me-2"></i>Nuevos mensajes (${c})`
: '<i class="fas fa-arrow-down me-2"></i>Nuevo mensaje';
el.style.display = 'inline-block';
el.style.pointerEvents = 'auto'; // Restaurar por si estaba deshabilitado
} catch (e) { console.warn('showNewMessagesIndicator failed', e); }
}
hideNewMessagesIndicator() {
try {
// remove any instances of the indicator (handle duplicates)
const els = document.querySelectorAll('#new-messages-indicator');
els.forEach(el => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} });
} catch (e) { /* ignore */ }
}
_removeUnreadHighlights() {
try {
console.log('🎨 Quitando highlights de mensajes no leídos');
const unreadMessages = document.querySelectorAll('.message.unread');
unreadMessages.forEach(msg => {
msg.classList.remove('unread');
delete msg.dataset.isNewUnread;
});
// Reset flag
this._nextMessageUnread = false;
} catch (e) {
console.warn('Error quitando highlights:', e);
}
}
applyStagedMessages() {
try {
// If nothing to apply, ensure indicator is gone
if (!this._stagedMessages || this._stagedMessages.length === 0) {
this.hideNewMessagesIndicator();
return;
}
// Mark as applying so we don't re-show indicator mid-apply
this._applyingStaged = true;
// Immediately hide indicator to provide instant feedback
this.hideNewMessagesIndicator();
const toApplyCount = this._stagedMessages.length;
console.log('✅ applyStagedMessages: aplicando', toApplyCount, 'mensajes');
// ENFOQUE SEGURO: En lugar de renderizar incrementalmente,
// simplemente recargar los mensajes desde el servidor.
// Esto es más seguro y evita problemas de renderizado.
const userId = this.currentUserId;
// Limpiar staged messages primero
this._stagedMessages = [];
if (this._stagedMessageIds) this._stagedMessageIds.clear();
this._stagedCount = 0;
// Recargar mensajes de forma segura
if (userId) {
this.loadingMessages = false; // Reset flag para permitir la carga
this.loadMessages(userId, true, true).then(() => {
console.log('✅ Mensajes recargados después de aplicar staged');
this.scrollToBottom();
}).catch(err => {
console.error('❌ Error recargando mensajes:', err);
// Fallback: intentar scroll al fondo de lo que ya hay
this.scrollToBottom();
});
}
} catch (e) {
console.warn('applyStagedMessages failed', e);
} finally {
// allow indicator to show again for future arrivals after short delay
setTimeout(() => { this._applyingStaged = false; }, 500);
}
}
getStatusIcon(status) {
switch (status) {
case 'sent': return '<i class="fas fa-check status-sent"></i>';
case 'delivered': return '<i class="fas fa-check-double status-delivered"></i>';
case 'read': return '<i class="fas fa-check-double status-read"></i>';
default: return '<i class="fas fa-clock status-sent"></i>';
}
}
// Update reaction badge for a single message in the DOM
updateMessageReactionInView(messageId, emoji) {
try {
const container = document.getElementById('chat-conversations');
if (!container) return;
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (!el) return;
let badge = el.querySelector('.reaction-badge');
if (emoji) {
if (badge) {
badge.textContent = emoji;
} else {
const div = document.createElement('div');
div.className = 'reaction-badge';
div.textContent = emoji;
const bubble = el.querySelector('.message-bubble');
if (bubble) bubble.insertBefore(div, bubble.querySelector('.message-actions'));
}
} else {
if (badge && badge.parentNode) badge.parentNode.removeChild(badge);
}
} catch (e) {
console.warn('updateMessageReactionInView failed', e);
}
}
// ========== FUNCIONES DE RECORDATORIO ==========
async showReminderModal() {
console.log('🔵 showReminderModal - currentUserId:', this.currentUserId);
console.log('📋 conversations:', this.conversations);
if (!this.currentUserId) {
alert('Selecciona una conversación primero');
return;
}
// Buscar usuario en conversaciones - probar múltiples formas
let user = this.conversations.find(c => c.id == this.currentUserId);
if (!user) {
user = this.conversations.find(c => c.user_id == this.currentUserId);
}
console.log('👤 Usuario encontrado:', user);
if (user) {
const userName = user.name || user.user_name || 'Sin nombre';
const phoneNumber = user.phone_number || user.phone || 'Sin teléfono';
document.getElementById('reminder-user-name').value = `${userName} (${phoneNumber})`;
console.log('✅ Usuario establecido:', userName, phoneNumber);
} else {
console.error('❌ Usuario no encontrado en conversaciones');
// Intentar obtener desde el DOM
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
const phoneEl = document.getElementById('chat-phone');
if (nameEl && phoneEl) {
document.getElementById('reminder-user-name').value = `${nameEl.textContent} (${phoneEl.textContent})`;
console.log('✅ Usuario obtenido del DOM');
} else {
document.getElementById('reminder-user-name').value = 'Usuario actual';
console.warn('⚠️ No se pudo obtener nombre del usuario');
}
}
// Establecer fecha mínima (hoy)
const today = new Date().toISOString().split('T')[0];
document.getElementById('reminder-date').setAttribute('min', today);
document.getElementById('reminder-date').value = today;
// Cargar plantillas aprobadas
await this.loadReminderTemplates();
// Mostrar modal
const modal = new bootstrap.Modal(document.getElementById('reminderModal'));
modal.show();
}
async loadReminderTemplates() {
try {
const response = await this.apiCall('get_templates.php?approved_only=1');
const select = document.getElementById('reminder-template');
if (response && response.success && response.data) {
select.innerHTML = '<option value="">Seleccionar plantilla...</option>';
response.data.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = `${template.name} (${template.language_code})`;
option.dataset.templateId = template.id;
option.dataset.templateName = template.template_name;
option.dataset.language = template.language_code;
option.dataset.bodyText = template.body_text || '';
// Detectar variables tanto numéricas como con nombres
option.dataset.hasVariables = template.body_text && /\{\{[^\}]+\}\}/.test(template.body_text);
select.appendChild(option);
});
}
} catch (error) {
console.error('Error loading templates:', error);
}
}
async handleReminderTemplateChange() {
const select = document.getElementById('reminder-template');
const selectedOption = select.options[select.selectedIndex];
console.log('🔵 handleReminderTemplateChange llamado');
console.log('📋 Option seleccionada:', selectedOption);
if (!selectedOption || !selectedOption.value) {
document.getElementById('reminder-variables-container').style.display = 'none';
document.getElementById('reminder-preview').style.display = 'none';
return;
}
const hasVariables = selectedOption.dataset.hasVariables === 'true';
const bodyText = selectedOption.dataset.bodyText || '';
console.log('🔍 hasVariables:', hasVariables);
console.log('📝 bodyText:', bodyText);
if (hasVariables) {
// Usar get_template_details.php para obtener variables con sus metadatos
try {
const templateId = selectedOption.dataset.templateId;
const detailsResp = await this.apiCall(`get_template_details.php?id=${templateId}`);
if (detailsResp && detailsResp.success && detailsResp.template) {
const variables = detailsResp.template.variables || [];
console.log('✅ Variables obtenidas de API:', variables);
// Generar campos
const fieldsContainer = document.getElementById('reminder-variables-fields');
fieldsContainer.innerHTML = '';
variables.forEach(v => {
const div = document.createElement('div');
div.className = 'mb-2';
div.innerHTML = `
<label class="form-label small">${v.label}</label>
<input type="text"
class="form-control form-control-sm reminder-variable-input"
data-index="${v.index}"
data-placeholder="${v.placeholder || ''}"
placeholder="${v.example || 'Ingrese valor...'}"
required>
`;
fieldsContainer.appendChild(div);
});
// Event listeners para actualizar preview
fieldsContainer.querySelectorAll('.reminder-variable-input').forEach(input => {
input.addEventListener('input', () => this.updateReminderPreview());
});
}
} catch (error) {
console.error('Error obteniendo detalles de plantilla:', error);
}
document.getElementById('reminder-variables-container').style.display = 'block';
} else {
document.getElementById('reminder-variables-container').style.display = 'none';
}
// Actualizar preview
this.updateReminderPreview();
}
updateReminderPreview() {
const select = document.getElementById('reminder-template');
const selectedOption = select.options[select.selectedIndex];
if (!selectedOption || !selectedOption.value) {
document.getElementById('reminder-preview').style.display = 'none';
return;
}
let bodyText = selectedOption.dataset.bodyText || '';
const hasVariables = selectedOption.dataset.hasVariables === 'true';
if (hasVariables) {
const inputs = document.querySelectorAll('.reminder-variable-input');
inputs.forEach(input => {
const index = input.dataset.index;
const value = input.value || `{{${index}}}`;
bodyText = bodyText.replace(new RegExp(`\\{\\{${index}\\}\\}`, 'g'), value);
});
}
const previewContainer = document.getElementById('reminder-preview-content');
previewContainer.innerHTML = bodyText.replace(/\n/g, '<br>');
document.getElementById('reminder-preview').style.display = 'block';
}
async saveReminder() {
if (!this.currentUserId) return;
const date = document.getElementById('reminder-date').value;
const time = document.getElementById('reminder-time').value;
const select = document.getElementById('reminder-template');
const selectedOption = select.options[select.selectedIndex];
if (!date || !time) {
alert('Por favor completa fecha y hora');
return;
}
if (!selectedOption || !selectedOption.value) {
alert('Por favor selecciona una plantilla');
return;
}
// Recopilar parámetros si hay variables
const hasVariables = selectedOption.dataset.hasVariables === 'true' || selectedOption.dataset.hasVariables === true;
console.log('🔍 hasVariables:', hasVariables, 'tipo:', typeof selectedOption.dataset.hasVariables);
let parameters = null;
if (hasVariables) {
const inputs = document.querySelectorAll('.reminder-variable-input');
console.log('📝 Inputs encontrados:', inputs.length);
if (inputs.length === 0) {
console.log('⚠️ No hay inputs de variables');
} else {
// Detectar tipo de variables usando el placeholder del primer input
const firstInput = inputs[0];
const placeholder = firstInput?.dataset?.placeholder || '';
const isNumericVar = /^\{\{\d+\}\}$/.test(placeholder);
console.log('🔍 Detectando tipo de variables en recordatorio:');
console.log(' - Placeholder ejemplo:', placeholder);
console.log(' - Es numérica?', isNumericVar);
let allFilled = true;
if (isNumericVar) {
// Variables numéricas: crear array ordenado
const tempObj = {};
inputs.forEach(input => {
const value = input.value.trim();
const index = parseInt(input.dataset.index || input.dataset.var);
if (!value) {
allFilled = false;
input.classList.add('is-invalid');
} else {
input.classList.remove('is-invalid');
tempObj[index] = value;
}
});
// Convertir a array ordenado
const sortedKeys = Object.keys(tempObj).map(Number).sort((a, b) => a - b);
parameters = sortedKeys.map(key => tempObj[key]);
console.log('📊 Enviando como array ordenado:', parameters);
} else {
// Variables con nombres: crear objeto
parameters = {};
inputs.forEach(input => {
const value = input.value.trim();
const placeholder = input.dataset.placeholder || '';
const varName = placeholder.replace(/\{\{|\}\}/g, '');
if (!value) {
allFilled = false;
input.classList.add('is-invalid');
} else {
input.classList.remove('is-invalid');
parameters[varName] = value;
}
});
console.log('📦 Enviando como objeto con nombres:', parameters);
}
if (!allFilled) {
alert('Por favor completa todas las variables');
return;
}
}
} else {
console.log('⚠️ Plantilla sin variables');
}
const templateId = selectedOption.dataset.templateId;
const templateName = selectedOption.dataset.templateName;
const language = selectedOption.dataset.language;
const payload = {
user_id: parseInt(this.currentUserId),
message_type: 'template',
template_id: parseInt(templateId),
template_name: templateName,
template_language: language,
template_parameters: parameters,
scheduled_date: date,
scheduled_time: time
};
console.log('💾 Guardando recordatorio:', payload);
console.log('📊 Parámetros detalle:', {
esNull: parameters === null,
esArray: Array.isArray(parameters),
longitud: parameters ? parameters.length : 0,
valores: parameters
});
try {
const response = await this.apiCall('schedule_message.php', {
body: payload
});
console.log('✅ Respuesta del servidor:', response);
if (response && response.success) {
alert('✅ Recordatorio programado exitosamente');
bootstrap.Modal.getInstance(document.getElementById('reminderModal')).hide();
// Limpiar formulario
document.getElementById('reminder-template').value = '';
document.getElementById('reminder-variables-container').style.display = 'none';
document.getElementById('reminder-preview').style.display = 'none';
} else {
alert('Error: ' + (response.error || 'Error desconocido'));
}
} catch (error) {
console.error('Error saving reminder:', error);
alert('Error al programar recordatorio: ' + error.message);
}
}
async loadQuickReplies() {
const panel = document.getElementById('quick-replies-panel');
if (!panel) return;
// Clear both lists
const qrList = panel.querySelector('.qr-list');
const tplList = panel.querySelector('.tpl-list');
const qrCount = panel.querySelector('#qr-count');
const tplCount = panel.querySelector('#tpl-count');
if (qrList) qrList.innerHTML = '';
if (tplList) tplList.innerHTML = '';
if (qrCount) qrCount.textContent = '0';
if (tplCount) tplCount.textContent = '0';
try {
// Load autoresponses (text quick replies)
const respText = await this.apiCall('get_autoresponses.php');
if (respText && respText.success && Array.isArray(respText.data)) {
let added = 0;
respText.data.forEach(r => {
if (!r.is_active) return;
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-primary';
let text = (r.response_text || '').replace(/\n/g,' ');
text = this.stripEmoji(text);
btn.textContent = text.length > 30 ? text.substring(0,30) + '...' : text;
btn.title = r.response_text;
btn.addEventListener('click', () => {
// send as text message and close panel
this.sendQuickReply(r.response_text);
panel.style.display = 'none';
document.getElementById('quick-replies-toggle').setAttribute('aria-expanded','false');
});
if (qrList) qrList.appendChild(btn);
added++;
});
if (qrCount) qrCount.textContent = String(added);
}
} catch (e) {
console.error('Error loading quick replies (text)', e);
}
try {
const resp = await this.apiCall('get_templates.php?approved_only=1&limit=30');
if (resp && resp.success && Array.isArray(resp.data)) {
let added = 0;
resp.data.forEach(t => {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-secondary';
let text = (t.body_text || t.name || t.template_name || '').replace(/\n/g,' ');
text = this.stripEmoji(text);
btn.textContent = text.length > 30 ? text.substring(0,30) + '...' : text;
btn.title = t.name;
btn.addEventListener('click', () => { this.sendTemplateQuick(t.template_name, t.language_code || 'es'); panel.style.display='none'; document.getElementById('quick-replies-toggle').setAttribute('aria-expanded','false'); });
if (tplList) tplList.appendChild(btn);
added++;
});
if (tplCount) tplCount.textContent = String(added);
}
} catch (e) {
console.error('Error loading quick replies (templates)', e);
}
// Also populate the template select
try { await loadTemplatesInto('#templateSelect'); } catch(e){console.warn('Could not load templates into select', e);}
// Show a small hint if there are no quick replies and no templates
const empty = panel.querySelector('#qr-empty');
const total = (qrList ? qrList.children.length : 0) + (tplList ? tplList.children.length : 0);
if (empty) empty.style.display = total ? 'none' : 'block';
// Trigger an initial filter (useful when search has an active value)
const sq = panel.querySelector('#quick-replies-search'); if (sq) sq.dispatchEvent(new Event('input'));
}
async sendTemplateQuick(templateName, language) {
console.log('🔵 sendTemplateQuick:', templateName, language);
// Primero, cargar detalles de la plantilla para verificar si tiene variables
try {
// Buscar el template_id desde la caché o hacer una petición
const templatesResp = await this.apiCall('get_templates.php?approved_only=1');
const template = templatesResp.data.find(t => t.template_name === templateName);
if (!template) {
throw new Error('Plantilla no encontrada');
}
console.log('📋 Template encontrado:', template);
// Verificar si tiene variables (tanto numéricas como con nombres)
const hasVariables = template.body_text && /\{\{[^\}]+\}\}/.test(template.body_text);
console.log('🔍 Tiene variables?', hasVariables);
if (hasVariables) {
// Mostrar modal para pedir variables
await this.showTemplateVariablesModal(template);
} else {
// Enviar directamente sin variables
await this.sendTemplateMessage(templateName, language, null);
}
} catch (error) {
console.error('❌ Error en sendTemplateQuick:', error);
alert('Error al cargar plantilla: ' + error.message);
}
}
async showTemplateVariablesModal(template) {
console.log('🔵 showTemplateVariablesModal:', template);
// Guardar template actual para usarlo al enviar
this.currentTemplate = template;
// Establecer nombre de plantilla en modal
document.getElementById('template-modal-name').textContent = template.name || template.template_name;
try {
// Usar get_template_details.php para obtener variables con metadatos
const detailsResp = await this.apiCall(`get_template_details.php?id=${template.id}`);
if (!detailsResp || !detailsResp.success) {
throw new Error('No se pudieron cargar los detalles de la plantilla');
}
const variables = detailsResp.template.variables || [];
console.log('📝 Variables obtenidas:', variables);
// Guardar template completo con variables para usarlo después
this.currentTemplate = { ...template, variables: variables };
// Generar campos de entrada
const container = document.getElementById('template-variables-container');
let html = '';
variables.forEach((variable) => {
html += `
<div class="mb-3">
<label class="form-label"><strong>${variable.label}</strong>
${variable.example ? `<small class="text-muted">(ej: ${variable.example})</small>` : ''}
</label>
<input
type="text"
class="form-control template-var-input"
data-var="${variable.index}"
data-placeholder="${variable.placeholder}"
placeholder="${variable.example || 'Ingrese valor...'}"
onkeyup="window.chatApp.updateTemplatePreview()"
required
>
</div>
`;
});
container.innerHTML = html;
// Generar vista previa inicial
this.updateTemplatePreview();
// Mostrar modal
const modal = new bootstrap.Modal(document.getElementById('templateVariablesModal'));
modal.show();
} catch (error) {
console.error('❌ Error cargando detalles de plantilla:', error);
alert('Error al cargar plantilla: ' + error.message);
}
}
async updateTemplatePreview() {
if (!this.currentTemplate) return;
const previewContainer = document.getElementById('template-preview');
const previewContent = document.getElementById('template-preview-content');
// Recopilar parámetros
const inputs = document.querySelectorAll('.template-var-input');
const parameters = [];
inputs.forEach(input => {
const varIndex = parseInt(input.dataset.var) - 1;
parameters[varIndex] = input.value || '';
});
try {
// Usar preview_template.php para renderizar correctamente
const response = await this.apiCall('preview_template.php', {
body: {
template_id: this.currentTemplate.id,
parameters: parameters
}
});
if (response && response.success && response.preview) {
previewContent.innerHTML = response.preview.html || response.preview.body.replace(/\n/g, '<br>');
previewContainer.style.display = 'block';
}
} catch (error) {
console.error('Error generando preview:', error);
// Fallback: mostrar body_text sin procesar
previewContent.innerHTML = this.currentTemplate.body_text.replace(/\n/g, '<br>');
previewContainer.style.display = 'block';
}
}
async sendTemplateWithVariables() {
console.log('🔵 sendTemplateWithVariables');
if (!this.currentTemplate) {
alert('No hay plantilla seleccionada');
return;
}
// Recopilar variables
const inputs = document.querySelectorAll('.template-var-input');
let allFilled = true;
// Detectar si son variables numéricas o con nombres usando el placeholder
const firstInput = inputs[0];
const placeholder = firstInput?.dataset?.placeholder || '';
// Si el placeholder es {{1}}, {{2}}, etc. -> numéricas
// Si es {{nombre_tema}}, {{fecha}}, etc. -> con nombres
const isNumericVar = /^\{\{\d+\}\}$/.test(placeholder);
console.log('🔍 Detectando tipo de variables:');
console.log(' - Placeholder ejemplo:', placeholder);
console.log(' - Es numérica?', isNumericVar);
let parameters;
if (isNumericVar) {
// Variables numéricas {{1}}, {{2}} - usar array
parameters = [];
inputs.forEach(input => {
const value = input.value.trim();
if (!value) {
allFilled = false;
input.classList.add('is-invalid');
} else {
input.classList.remove('is-invalid');
parameters.push(value);
}
});
console.log('📊 Enviando como array:', parameters);
} else {
// Variables con nombres {{fecha}}, {{motivo}} - usar objeto
parameters = {};
inputs.forEach(input => {
const value = input.value.trim();
const placeholder = input.dataset.placeholder || '';
// Extraer nombre de {{fecha}} -> "fecha"
const varName = placeholder.replace(/\{\{|\}\}/g, '');
if (!value) {
allFilled = false;
input.classList.add('is-invalid');
} else {
input.classList.remove('is-invalid');
parameters[varName] = value;
}
});
console.log('📦 Enviando como objeto:', parameters);
}
if (!allFilled) {
alert('Por favor completa todas las variables');
return;
}
console.log('📝 Parámetros finales:', parameters);
// Cerrar modal
const modal = bootstrap.Modal.getInstance(document.getElementById('templateVariablesModal'));
if (modal) modal.hide();
// Enviar plantilla con parámetros
await this.sendTemplateMessage(
this.currentTemplate.template_name,
this.currentTemplate.language_code || 'es',
parameters
);
// Limpiar template actual
this.currentTemplate = null;
}
async sendTemplateMessage(templateName, language = 'es', parameters = null) {
if (!this.currentUserId) return;
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino para la plantilla');
console.error('sendTemplateMessage: missing recipient for user', this.currentUserId);
return;
}
try {
// Build parameters: support NAMED parameters for certain templates
let paramsPayload = parameters || [];
// If the template expects a named parameter `name`, provide it using user name or phone
if (templateName === 'contacto_nuevo') {
// Try to find conversation info
let conv = this.conversations.find(c => c.user_id === this.currentUserId) || {};
let resolvedName = (conv.name || conv.full_name || '').trim();
if (!resolvedName) {
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
if (nameEl && nameEl.textContent) resolvedName = nameEl.textContent.trim();
}
// If no resolvedName and parameters don't include a name, prompt the operator for the name
const hasNameInParams = (paramsPayload && typeof paramsPayload === 'object' && (paramsPayload.name || (Array.isArray(paramsPayload) && paramsPayload.some(p => p.parameter_name === 'name' || p.name === 'name'))));
if (!resolvedName && !hasNameInParams) {
const inputName = prompt('Ingrese el nombre del paciente para la plantilla (dejar vacío para usar el número):');
if (inputName === null) {
// user cancelled
return;
}
resolvedName = inputName.trim() || '';
}
if (!resolvedName) resolvedName = recipient; // fallback to phone number
// Use an associative object so backend treats it as NAMED parameter
paramsPayload = { name: resolvedName };
}
const requestBody = {
recipient: recipient,
type: 'template',
template: templateName,
language: language || 'es',
parameters: paramsPayload
};
const resp = await this.apiCall('send_message.php', { body: requestBody });
// hide reply preview if any
this.hideReplyPreview();
if (resp && resp.success) {
const replyToTemplate = document.getElementById('message-input').dataset.replyTo || null;
this.addMessageToView(`Plantilla: ${templateName}`, 'outgoing', { reply_to_message_id: replyToTemplate });
typeof showAlert !== 'undefined' && showAlert('Mensaje de plantilla enviado correctamente', 'success');
await this.loadconversations(this.currentUserId, false);
// SSE actualizará las conversaciones automáticamente
// await this.loadConversations();
} else {
throw new Error(resp && resp.error ? resp.error : 'Error enviando plantilla');
}
} catch (e) {
console.error('Error sending template message', e);
alert('Error enviando plantilla: ' + (e.message || e));
}
}
async sendQuickReply(text) {
if (!text) return;
if (!this.currentUserId) return;
const input = document.getElementById('message-input');
if (!input) return;
// No copiar al input para preservar saltos de línea
// Enviar directamente usando la API
input.disabled = true;
const sendBtn = document.getElementById('send-btn');
if (sendBtn) sendBtn.disabled = true;
try {
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino');
return;
}
const requestBody = {
recipient: recipient,
type: 'text',
message: text
};
const result = await this.apiCall('send_message.php', { body: requestBody });
if (result && result.success) {
// Mostrar inmediatamente en la vista
this.addMessageToView(text, 'outgoing');
typeof showAlert !== 'undefined' && showAlert('Mensaje enviado correctamente', 'success');
// Recargar mensajes en background
this.loadconversations(this.currentUserId, false).catch(e=>console.warn(e));
} else {
throw new Error(result && result.error ? result.error : 'Error desconocido');
}
} catch (error) {
console.error('Error sending quick reply:', error);
alert('Error al enviar respuesta rápida: ' + (error.message || error));
} finally {
input.disabled = false;
if (sendBtn) sendBtn.disabled = false;
input.focus();
}
}
scrollToBottom() {
const container = document.getElementById('chat-conversations');
container.scrollTop = container.scrollHeight;
}
_showCopyToast(bubble) {
const existing = bubble.querySelector('.copy-feedback');
if (existing) existing.remove();
const tip = document.createElement('span');
tip.className = 'copy-feedback';
tip.textContent = '✓ Copiado';
bubble.appendChild(tip);
setTimeout(() => { if (tip.parentNode) tip.remove(); }, 1800);
}
// Insertar mensaje saliente en la vista inmediatamente (simular envío)
addMessageToView(content, type = 'outgoing', options = {}) {
const msg = {
message_id: 'local_' + Date.now(),
direction: type === 'outgoing' ? 'outgoing' : 'incoming',
content: content,
created_at: new Date().toISOString(),
status: 'sent'
};
// Soportar reply preview inmediato
if (options && options.reply_to_message_id) {
msg.reply_to_message_id = options.reply_to_message_id;
}
// Soportar attachments/meta si es necesario
if (options && options.message_type) {
msg.message_type = options.message_type;
}
this.currentMessages.push(msg);
this.renderMessagesIncremental();
this.scrollToBottom();
}
async showReplyPreview(messageId, type = 'reply') {
try {
const preview = document.getElementById('reply-preview');
const previewText = document.getElementById('reply-preview-text');
const cancelBtn = document.getElementById('cancel-reply-btn');
let msg = this.currentMessages.find(m => m.message_id == messageId || m.id == messageId);
const setPreviewFromMsg = (m) => {
const text = m ? (m.content || m.message_text || (m.caption || '[Mensaje]')) : ('Mensaje ' + messageId);
if (preview && previewText) {
if (type === 'reply') previewText.textContent = 'En respuesta a: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
else if (type === 'forward') previewText.textContent = 'Reenviando: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
preview.style.display = 'flex';
}
};
if (!msg) {
// intentar obtener mensaje por API
try {
const resp = await fetch(`api/get_message.php?message_id=${encodeURIComponent(messageId)}`, { cache: 'no-store' });
if (resp.ok) {
const j = await resp.json();
if (j && j.success && j.data) {
msg = j.data;
}
}
} catch (err) {
console.warn('Could not fetch referenced message', err);
}
}
setPreviewFromMsg(msg);
if (cancelBtn) {
cancelBtn.onclick = () => this.hideReplyPreview();
}
} catch (e) {
console.warn('showReplyPreview failed', e);
}
}
hideReplyPreview() {
try {
const preview = document.getElementById('reply-preview');
if (preview) preview.style.display = 'none';
const input = document.getElementById('message-input');
if (input) {
input.dataset.replyTo = null;
input.dataset.forwardTo = null;
}
} catch (e) {
console.warn('hideReplyPreview failed', e);
}
}
async sendMessage() {
// If a file is selected (media preview active), delegate to sendMediaMessage to avoid duplicate sends
if (this.selectedFile) {
await this.sendMediaMessage();
return;
}
const input = document.getElementById('message-input');
const message = input.value.trim();
const messageType = document.getElementById('message-type') ? document.getElementById('message-type').value : 'text';
if (!message && messageType === 'text') return;
if (!this.currentUserId) return;
// Deshabilitar input
input.disabled = true;
document.getElementById('send-btn').disabled = true;
try {
const replyTo = input.dataset.replyTo || null;
if (messageType === 'template') {
const template = document.getElementById('templateSelect').value;
if (!template) {
alert('Seleccione una plantilla');
return;
}
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino para la plantilla');
console.error('sendMessage(template): missing recipient for user', this.currentUserId);
return;
}
// send template via helper (match chat_window format)
const templateName = template;
const language = (document.getElementById('templateSelect').selectedOptions[0] ? document.getElementById('templateSelect').selectedOptions[0].dataset.language : 'es');
await this.sendTemplateMessage(templateName, language, null);
} else {
// send text message using recipient (phone number) to match chat_window behavior
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino');
console.error('sendMessage(text): missing recipient for user', this.currentUserId);
return;
}
// prepare request body similar to chat_window
const requestBody = {
recipient: recipient,
type: 'text',
message: message
};
if (replyTo) requestBody.reply_to = replyTo;
try {
const result = await this.apiCall('send_message.php', { body: requestBody });
// Clear reply data attribute
input.dataset.replyTo = null;
this.hideReplyPreview();
if (result && result.success) {
input.value = '';
// Mostrar inmediatamente en la vista
this.addMessageToView(message, 'outgoing', { reply_to_message_id: replyTo });
typeof showAlert !== 'undefined' && showAlert('Mensaje enviado correctamente', 'success');
// Recargar mensajes y conversaciones en background
this.loadconversations(this.currentUserId, false).catch(e=>console.warn(e));
// SSE actualizará las conversaciones automáticamente
// this.loadConversations().catch(e=>console.warn(e));
// loadQuickReplies ya se cargó al abrir la conversación, no es necesario recargar después de cada mensaje
} else {
throw new Error(result && result.error ? result.error : 'Error desconocido');
}
} catch (err) {
console.error('Error sending text message', err);
alert('Error al enviar mensaje: ' + (err.message || err));
}
}
} catch (error) {
console.error('Error sending message:', error);
alert('Error al enviar mensaje: ' + (error.message||error));
} finally {
input.disabled = false;
document.getElementById('send-btn').disabled = false;
input.focus();
// Ensure send/mic visibility is recalculated (restore mic when input empty)
try { if (typeof this._updateSendMicVisibility === 'function') this._updateSendMicVisibility(); } catch (e) { console.warn('updateSendMicVisibility failed', e); }
}
}
// loadQuickReplies is implemented earlier (combined autoresponses & templates)
// kept here as a no-op to avoid accidentally overriding the real implementation.
async promptEditUser() {
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
const currentName = nameEl ? nameEl.textContent.trim() : '';
const newName = prompt('Nombre del usuario:', currentName);
if (!newName) return;
try {
const resp = await fetch('api/update_user.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId, name: newName }),
cache: 'no-store'
});
const json = await resp.json();
if (json && json.success) {
if (nameEl) nameEl.textContent = newName;
this.showSuccess && this.showSuccess('Usuario actualizado');
this.loadConversations();
} else {
this.showError && this.showError('No se pudo actualizar usuario');
}
} catch (err) {
console.error(err);
this.showError && this.showError('Error actualizando usuario');
}
}
async deleteCurrentConversation() {
if (!confirm('¿Eliminar esta conversación? Se borrará todo el historial.')) return;
try {
const resp = await fetch('api/delete_conversation.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId }),
cache: 'no-store'
});
const json = await resp.json();
if (json && json.success) {
this.showSuccess('Conversación eliminada');
// reset UI
document.getElementById('chat-area').style.display = 'none';
document.getElementById('no-conversation').style.display = 'block';
this.currentUserId = null;
this.loadConversations();
} else {
this.showError('No se pudo eliminar la conversación');
}
} catch (err) {
console.error(err);
this.showError('Error eliminando la conversación');
}
}
async deleteCurrentConversation() {
if (!confirm('¿Eliminar esta conversación? Se borrará todo el historial.')) return;
try {
const resp = await fetch('api/delete_conversation.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId }),
cache: 'no-store'
});
const json = await resp.json();
if (json && json.success) {
this.showSuccess('Conversación eliminada');
// reset UI
document.getElementById('chat-area').style.display = 'none';
document.getElementById('no-conversation').style.display = 'block';
this.currentUserId = null;
this.loadConversations();
} else {
this.showError('No se pudo eliminar la conversación');
}
} catch (err) {
console.error(err);
this.showError('Error eliminando la conversación');
}
}
// ========== SOLICITAR ARCHIVO GRANDE ==========
async requestLargeFile() {
if (!this.currentUserId) {
showAlert('Selecciona una conversación primero', 'warning');
return;
}
const phoneNumber = this.getConversationPhone(this.currentUserId);
if (!phoneNumber) {
showAlert('No se encontró el número de teléfono del cliente', 'danger');
return;
}
// Confirmar con el operador
const userName = document.getElementById('chat-name-text')?.textContent?.trim() || phoneNumber;
if (!confirm(`¿Enviar enlace de carga de archivos grandes a ${userName}?\n\nSe enviará un mensaje por WhatsApp con un enlace seguro donde el cliente podrá subir archivos de hasta 50 MB.`)) {
return;
}
// Deshabilitar botones mientras se procesa
const headerBtn = document.getElementById('request-large-file-btn');
if (headerBtn) {
headerBtn.disabled = true;
headerBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
}
try {
const response = await fetch('api/request_large_file.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
user_id: this.currentUserId,
phone_number: phoneNumber,
}),
cache: 'no-store',
});
const result = await response.json();
if (result && result.success) {
showAlert('✅ Enlace de carga enviado al cliente por WhatsApp', 'success');
console.log('📎 File request created:', result);
// Agregar mensaje visual al chat con texto más detallado
this.addMessageToView(
`📎 Enlace para enviar archivos grandes\n\n` +
`Hola.\n\n` +
`Le enviamos este enlace seguro para que pueda subir archivos de gran tamaño (hasta 50 MB):\n\n` +
`👉 ${result.upload_url}\n\n` +
`✅ Puede subir imágenes o documentos.\n` +
`🔒 El enlace es seguro y exclusivo para usted.\n` +
`⏰ Válido por 24 horas.\n\n` +
`Si tiene alguna duda, estamos aquí para ayudarle.`,
'outgoing',
{ message_type: 'text' }
);
// Recargar mensajes después de un breve delay
setTimeout(() => {
this.loadMessages(this.currentUserId, false, true);
}, 2000);
} else {
showAlert('Error: ' + (result.error || 'No se pudo enviar el enlace'), 'danger');
}
} catch (error) {
console.error('Error en requestLargeFile:', error);
showAlert('Error al enviar la solicitud: ' + (error.message || error), 'danger');
} finally {
if (headerBtn) {
headerBtn.disabled = false;
headerBtn.innerHTML = '<i class="fas fa-cloud-upload-alt"></i>';
}
}
}
async getFileRequests() {
if (!this.currentUserId) return [];
try {
const resp = await fetch(`api/get_file_requests.php?user_id=${this.currentUserId}&status=active`, {
credentials: 'same-origin',
cache: 'no-store',
});
const data = await resp.json();
return data.success ? (data.uploads || []) : [];
} catch (e) {
console.warn('Error loading file requests:', e);
return [];
}
}
async searchConversations(query) {
console.log('🔍 Buscando conversaciones:', query);
// Limpiar búsqueda si el query está vacío
if (!query || query.trim() === '') {
// Recargar conversaciones normales
await this.loadConversations(1, false);
return;
}
// Buscar en la base de datos
try {
const response = await this.apiCall(`get_conversations.php?search=${encodeURIComponent(query.trim())}`);
if (response && response.success) {
this.conversations = response.data || [];
this.currentPage = 1;
this.hasMore = false; // Desactivar paginación en búsqueda
this.renderConversations();
console.log(`✅ Búsqueda completada: ${this.conversations.length} resultados`);
if (this.conversations.length === 0) {
const container = document.getElementById('conversation-list');
container.innerHTML = `
<div style="padding: 20px; text-align: center; color: #999;">
<i class="fas fa-search" style="font-size: 48px; margin-bottom: 10px; opacity: 0.3;"></i>
<p>No se encontraron conversaciones con "${query}"</p>
<small>Intenta buscar por nombre, teléfono o contenido del mensaje</small>
</div>
`;
}
} else {
console.error('Error en búsqueda:', response);
// Fallback a búsqueda local si falla el servidor
this.searchConversationsLocal(query);
}
} catch (error) {
console.error('Error buscando conversaciones:', error);
// Fallback a búsqueda local
this.searchConversationsLocal(query);
}
}
// Búsqueda local (fallback) - solo filtra las conversaciones ya cargadas
searchConversationsLocal(query) {
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const name = item.querySelector('.conversation-name').textContent.toLowerCase();
const preview = item.querySelector('.conversation-preview').textContent.toLowerCase();
const matches = name.includes(query.toLowerCase()) || preview.includes(query.toLowerCase());
item.style.display = matches ? 'flex' : 'none';
});
}
// ========== FUNCIONES MULTIMEDIA ==========
handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
// NO es grabación de voz (es archivo subido)
this._isVoiceRecording = false;
// Validar tamaño
const maxSize = this.getMaxFileSize(file.type);
if (file.size > maxSize) {
alert(`El archivo es demasiado grande. Máximo: ${(maxSize / 1024 / 1024).toFixed(0)}MB`);
return;
}
// Mostrar preview
this.showMediaPreview(file);
}
getMaxFileSize(fileType) {
if (fileType.startsWith('image/')) return 5 * 1024 * 1024; // 5MB
if (fileType.startsWith('application/')) return 100 * 1024 * 1024; // 100MB
return 16 * 1024 * 1024; // 16MB para video/audio
}
showMediaPreview(file) {
const preview = document.getElementById('media-preview');
const thumbnail = document.getElementById('preview-thumbnail');
const filename = document.getElementById('preview-filename');
const filesize = document.getElementById('preview-filesize');
// Establecer información del archivo
filename.textContent = file.name;
filesize.textContent = this.formatFileSize(file.size);
// Mostrar thumbnail para imágenes
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
thumbnail.src = e.target.result;
thumbnail.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
thumbnail.style.display = 'none';
}
// Guardar archivo temporalmente
this.selectedFile = file;
// Mostrar preview
preview.style.display = 'block';
// Show delete button next to send
const delBtn = document.getElementById('delete-file-btn');
if (delBtn) {
delBtn.style.display = 'inline-flex';
delBtn.onclick = () => { this.cancelMediaUpload(); };
}
// Use the main send handler: `sendMessage` will delegate to media send when a file is selected
const sendBtn = document.getElementById('send-btn');
// ensure send button is enabled
if (sendBtn) sendBtn.disabled = false;
// Update mic/send visibility (hide mic when a file is selected)
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Remove common emoji/pictograph characters for minimal quick reply labels
stripEmoji(text) {
if (!text) return '';
try {
return String(text).replace(/[\u{1F300}-\u{1FAFF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, '').trim();
} catch (e) {
return String(text);
}
}
async sendMediaMessage() {
if (!this.selectedFile || !this.currentUserId) return;
const caption = document.getElementById('media-caption').value.trim();
const sendBtn = document.getElementById('send-btn');
const attachBtn = document.getElementById('attach-btn');
// Deshabilitar botones
sendBtn.disabled = true;
attachBtn.disabled = true;
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
// 1. Subir archivo
const formData = new FormData();
formData.append('file', this.selectedFile);
const uploadResponse = await fetch('api/upload_media.php', {
method: 'POST',
credentials: 'same-origin', // Enviar cookies de sesión
body: formData
});
const uploadContentType = uploadResponse.headers.get('content-type') || '';
let uploadResult;
if (uploadContentType.indexOf('application/json') !== -1) {
uploadResult = await uploadResponse.json();
} else {
const text = await uploadResponse.text();
console.error('upload_media returned non-JSON response:\n', text);
throw new Error('Respuesta inválida de upload_media.php. Ver consola para más detalles.');
}
if (!uploadResult.success) {
throw new Error(uploadResult.error || 'Error subiendo archivo');
}
// 2. Enviar mensaje con el archivo
const phone = this.getConversationPhone(this.currentUserId);
if (!phone) {
throw new Error('No se encontró el número de teléfono del usuario');
}
const sendResponse = await fetch('api/send_media_message.php', {
method: 'POST',
credentials: 'same-origin', // Enviar cookies de sesión
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipient: phone,
media_url: uploadResult.data.url,
media_type: uploadResult.data.type,
caption: caption || null,
filename: this.selectedFile.name,
// Para audio: is_voice = true si fue grabado desde micrófono (nota de voz)
// is_voice = false si fue subido como archivo (audio normal)
is_voice: (uploadResult.data.type === 'audio' && this._isVoiceRecording) ? true : false
})
});
const sendContentType = sendResponse.headers.get('content-type') || '';
let sendResult = null;
// Leer el body una sola vez para evitar error "body stream already read"
const sendResponseText = await sendResponse.text();
if (sendContentType.indexOf('application/json') !== -1) {
try {
sendResult = JSON.parse(sendResponseText);
} catch (err) {
// Content-Type claims JSON but parsing failed: attempt to extract JSON from body
console.warn('send_media_message: Content-Type JSON but parse failed. Response body will be inspected for JSON.');
console.warn(sendResponseText);
const m = sendResponseText.match(/(\{[\s\S]*\})/);
if (m) {
try { sendResult = JSON.parse(m[1]); console.warn('send_media_message: extracted JSON from response.'); } catch (e) { console.warn('send_media_message: extracted JSON parse failed', e); }
}
if (!sendResult) {
// If HTTP status is OK, be forgiving: treat as success but log for investigation
if (sendResponse.ok) {
console.warn('send_media_message: non-parseable JSON returned but HTTP ok; treating as success.');
sendResult = { success: true };
} else {
throw new Error('Respuesta JSON inválida de send_media_message.php. Ver consola para más detalles.');
}
}
}
} else {
// Non-JSON content-type: try to find embedded JSON; if HTTP 200, be forgiving
console.warn('send_media_message returned non-JSON response:');
console.warn(sendResponseText.slice(0, 2000));
const m = sendResponseText.match(/(\{[\s\S]*\})/);
if (m) {
try { sendResult = JSON.parse(m[1]); console.warn('send_media_message: extracted JSON from non-JSON response.'); } catch (e) { console.warn('send_media_message: failed to parse extracted JSON', e); }
}
if (!sendResult) {
if (sendResponse.ok) {
// Message was likely sent successfully despite odd response — avoid showing false error to user
console.warn('send_media_message: non-JSON response but HTTP OK. Treating as success.');
sendResult = { success: true };
} else {
console.error('send_media_message returned non-JSON response and HTTP not OK.');
throw new Error('Respuesta inválida de send_media_message.php. Revisa logs del servidor (send_media_message_debug.log y el servidor PHP) para más detalles.');
}
}
}
if (!sendResult.success) {
throw new Error(sendResult.error || 'Error enviando mensaje');
}
// Éxito
console.log('✅ Archivo enviado exitosamente, recargando mensajes...');
this.cancelMediaUpload();
// Recargar mensajes para mostrar el multimedia recién enviado
await this.loadMessages(this.currentUserId, true, true);
// SSE actualizará las conversaciones en la lista automáticamente
// this.loadConversations();
} catch (error) {
console.error('Error sending media:', error);
alert('Error al enviar archivo: ' + error.message);
} finally {
sendBtn.disabled = false;
attachBtn.disabled = false;
sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>';
}
}
cancelMediaUpload() {
document.getElementById('media-preview').style.display = 'none';
document.getElementById('media-caption').value = '';
document.getElementById('file-input').value = '';
this.selectedFile = null;
// Limpiar flag de grabación de voz
this._isVoiceRecording = false;
// Restaurar botón enviar
const sendBtn = document.getElementById('send-btn');
if (sendBtn) { sendBtn.onclick = null; sendBtn.disabled = false; sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>'; }
const delBtn = document.getElementById('delete-file-btn'); if (delBtn) delBtn.style.display = 'none';
// Ensure mic/send visibility reflects the current state
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
}
renderMediaMessage(message) {
if (!message) return '';
const mediaType = message.message_type || message.media_type || 'text';
const mediaUrl = message.media_url || '';
const mediaUrlExternal = message.media_url_external || '';
const caption = message.content || '';
const localFile = message.local_file || '';
const localThumb = message.local_thumb || '';
const messageId = message.id || message.message_id || '';
// Obtener la URL base del sitio para URLs absolutas
const baseUrl = window.location.origin;
// Debug completo del mensaje
if (mediaType === 'audio' || mediaType === 'image' || mediaType === 'video') {
console.log(`🎵 Media message (${mediaType}) - Datos completos:`, {
id: messageId,
localFile: localFile,
localThumb: localThumb,
mediaUrl: mediaUrl,
mediaUrlExternal: mediaUrlExternal,
hasLocalFile: !!localFile,
messageType: mediaType,
baseUrl: baseUrl
});
}
// Prioridad: archivo local > proxy con DB id > media_url_external > URL externa
let full = '';
let thumb = '';
if (localFile) {
// ✅ PRIORIDAD 1: Archivo local guardado (usar URL absoluta)
full = localFile.startsWith('http') ? localFile : `${baseUrl}/${localFile.replace(/^\//, '')}`;
thumb = localThumb
? (localThumb.startsWith('http') ? localThumb : `${baseUrl}/${localThumb.replace(/^\//, '')}`)
: full;
console.log('✅ Usando archivo local (URL absoluta):', full);
} else if (messageId && !mediaUrlExternal) {
// ⚠️ PRIORIDAD 2: Usar proxy con el ID de la base de datos (URL absoluta)
full = `${baseUrl}/api/version/media-url.php?id=${encodeURIComponent(messageId)}`;
thumb = full;
console.log('⚠️ No hay local_file, usando proxy con messageId:', messageId);
} else if (mediaUrlExternal) {
// 🔄 PRIORIDAD 3: Usar media_url_external (convertir a URL absoluta si es relativa)
full = mediaUrlExternal.startsWith('http')
? mediaUrlExternal
: `${baseUrl}/${mediaUrlExternal.replace(/^\//, '')}`;
thumb = full;
console.log('🔄 Usando media_url_external (URL absoluta):', full);
} else if (mediaUrl && /^\d+$/.test(mediaUrl)) {
// 📱 PRIORIDAD 4: Es un ID de WhatsApp (solo números) - usar URL absoluta
full = `${baseUrl}/api/version/media-url.php?id=${encodeURIComponent(mediaUrl)}`;
thumb = full;
console.log('📱 Usando whatsapp_media_id con media-url.php:', mediaUrl);
} else if (mediaUrl && /^https?:\/\//i.test(mediaUrl)) {
// 🌐 PRIORIDAD 5: URL externa directa (puede estar caducada)
full = mediaUrl;
thumb = mediaUrl;
console.log('🌐 Usando URL externa:', full);
} else {
// ❌ Fallback: sin media disponible
console.warn('❌ No hay fuente de media disponible para el mensaje', messageId);
full = '';
thumb = '';
}
// Si no hay URL de media, mostrar placeholder con botón de descarga inmediata
if (!full && mediaType !== 'text') {
const iconMap = {image: 'fa-image', video: 'fa-video', audio: 'fa-microphone', document: 'fa-file', sticker: 'fa-sticky-note'};
const icon = iconMap[mediaType] || 'fa-file';
const labelMap = {image: 'imagen', video: 'video', audio: 'audio', document: 'documento', sticker: 'sticker'};
const label = labelMap[mediaType] || mediaType;
const btnId = `dl-btn-${messageId}`;
const statusId = `dl-status-${messageId}`;
// Botón descarga on-demand
const hasId = messageId || (mediaUrl && /^\d+$/.test(mediaUrl));
const downloadBtn = hasId ? `
<button id="${escapeHtml(btnId)}" class="btn btn-sm btn-success mt-2" style="border-radius:20px; font-size:11px; padding:3px 12px;"
onclick="window.forceDownloadMedia(${escapeHtml(String(messageId))}, '${escapeHtml(btnId)}', '${escapeHtml(statusId)}'); return false;">
<i class='fas fa-download'></i> Descargar ahora
</button>
<div id="${escapeHtml(statusId)}" style="font-size:11px; color:#aaa; margin-top:4px;"></div>
` : '';
return `
<div class="message-media" style="background:rgba(0,0,0,0.04); border-radius:8px; padding:12px; text-align:center; min-width:180px;">
<i class="fas ${icon}" style="font-size:28px; color:#999; margin-bottom:6px; display:block;"></i>
<div style="font-size:12px; color:#888;">Pendiente de descarga (${escapeHtml(label)})</div>
${downloadBtn}
</div>
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
`;
}
switch (mediaType) {
case 'image':
return `
<div class="message-media">
<a href="#" data-full="${escapeHtml(full)}" data-type="image" data-caption="${escapeHtml(caption)}" onclick="openMediaLightbox(this.dataset.full, this.dataset.type, this.dataset.caption); return false;" title="Abrir imagen">
<img src="${escapeHtml(thumb)}" alt="Imagen" style="cursor:zoom-in" onerror="this.parentElement.parentElement.innerHTML='<div style=\\'padding:12px;text-align:center;background:rgba(0,0,0,0.04);border-radius:8px;\\'><i class=\\'fas fa-image\\' style=\\'font-size:28px;color:#ccc;\\'></i><div style=\\'font-size:12px;color:#999;margin-top:4px;\\'>Imagen no disponible</div><a href=\\'${escapeHtml(full)}\\' target=\\'_blank\\' class=\\'btn btn-sm btn-outline-primary mt-1\\'><i class=\\'fas fa-download\\'></i> Reintentar</a></div>'">
</a>
</div>
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
`;
case 'video':
return `
<div class="message-media">
<a href="#" data-full="${escapeHtml(full)}" data-type="video" data-caption="${escapeHtml(caption)}" onclick="openMediaLightbox(this.dataset.full, this.dataset.type, this.dataset.caption); return false;" title="Abrir video">
<div style="position:relative; display:inline-block;">
<img src="${escapeHtml(thumb)}" alt="Video" style="cursor:zoom-in; max-width:100%; border-radius:6px;">
<div style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); font-size:28px; color:white; text-shadow:0 1px 6px rgba(0,0,0,0.6);"><i class="fas fa-play-circle"></i></div>
</div>
</a>
</div>
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
`;
case 'audio': {
// Para audio, usar la misma lógica de full
let audioSrc = full;
if (!audioSrc) {
return `
<div class="message-media">
<div class="alert alert-warning mb-0" style="font-size: 13px;">
<i class="fas fa-exclamation-triangle"></i> Audio no disponible
<br><small>El audio puede haber expirado o no está disponible</small>
</div>
</div>
`;
}
return `
<div class="message-media">
<audio controls controlsList="nodownload" preload="metadata" onerror="this.parentElement.innerHTML='<div class=\\'alert alert-danger mb-0\\' style=\\'font-size:13px;\\'><i class=\\'fas fa-times-circle\\'></i> Error al cargar audio<br><small>Archivo no encontrado o no disponible</small></div>'">
<source src="${escapeHtml(audioSrc)}" type="audio/mpeg">
<source src="${escapeHtml(audioSrc)}" type="audio/ogg">
<source src="${escapeHtml(audioSrc)}" type="audio/wav">
Tu navegador no soporta reproducción de audio.
</audio>
</div>
`;
}
case 'document': {
let docUrl = full;
if (!docUrl) {
return `
<div class="message-document disabled">
<i class="fas fa-file-pdf"></i>
<div class="document-info">
<div class="document-name">${escapeHtml(caption || 'Documento')}</div>
<div class="document-size text-muted">No disponible para descargar</div>
</div>
</div>
`;
}
return `
<div class="message-document">
<i class="fas fa-file-pdf"></i>
<div class="document-info">
<div class="document-name">${escapeHtml(caption || 'Documento')}</div>
<div class="document-size">
<a href="${escapeHtml(docUrl)}" target="_blank" rel="noopener noreferrer" ${docUrl && docUrl.indexOf('download=1') !== -1 ? 'download' : ''}>Descargar</a>
</div>
</div>
<i class="fas fa-download"></i>
</div>
`;
}
default:
return escapeHtml(caption) || '';
}
}
}
// Función global para cancelar
function cancelMediaUpload() {
if (window.chatApp) {
window.chatApp.cancelMediaUpload();
}
}
// Helper para renderizar WhatsApp-like formatted text and interactive messages
function renderInteractiveMessage(obj) {
try {
const interactive = obj.interactive || obj;
const t = interactive.type || (interactive.header && 'button');
if (t === 'button' || interactive.button) {
const title = escapeHtml(interactive.title || interactive.body || '');
const buttons = interactive.buttons || interactive.button || [];
const html = [`<div class="wa-interactive">`, title ? `<div class="wa-title">${title}</div>` : ''];
html.push(`<div class="wa-buttons">`);
buttons.forEach(b => {
const label = escapeHtml(b.title || b.text || b.body || b.label || '');
html.push(`<button class="btn btn-sm btn-outline-primary wa-interactive-btn" data-value="${label}">${label}</button>`);
});
html.push(`</div></div>`);
return html.join('');
}
if (t === 'list' || interactive.sections) {
const title = escapeHtml(interactive.title || interactive.header || '');
const sections = interactive.sections || [];
const html = [`<div class="wa-interactive">`, title ? `<div class="wa-title">${title}</div>` : ''];
html.push(`<div class="wa-list">`);
sections.forEach(s => {
const secTitle = escapeHtml(s.title || '');
if (secTitle) html.push(`<div class="wa-section-title">${secTitle}</div>`);
(s.rows || []).forEach(r => {
const rowTitle = escapeHtml(r.title || r.name || r.id || '');
const rowDesc = escapeHtml(r.description || '');
html.push(`<div class="wa-list-item" data-value="${rowTitle}"><div class="wa-list-name">${rowTitle}</div>${rowDesc?`<div class="wa-list-desc small text-muted">${rowDesc}</div>`:''}</div>`);
});
});
html.push(`</div></div>`);
return html.join('');
}
} catch (e) { console.warn('renderInteractiveMessage failed', e); }
return '';
}
function formatMessageInlineCode(s) {
return s.replace(/`([^`]+)`/g, (m, g1) => `<span class="wa-inline-code">${escapeHtml(g1)}</span>`);
}
function formatMessageContent(text) {
if (!text && text !== 0) return '';
try {
// If looks like JSON with interactive payload, render interactive UI
const t = String(text).trim();
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
try {
const parsed = JSON.parse(t);
if (parsed && (parsed.interactive || parsed.type === 'interactive')) {
return renderInteractiveMessage(parsed.interactive || parsed);
}
} catch (e) { /* not json */ }
}
// Escape HTML first
let s = escapeHtml(String(text));
// Code block (triple backticks) -> <pre>
s = s.replace(/```([\s\S]*?)```/g, (m, g1) => `<div class="wa-code">${escapeHtml(g1)}</div>`);
// Inline code
s = formatMessageInlineCode(s);
// Bold *text*
s = s.replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
// Italic _text_
s = s.replace(/_([^_]+)_/g, '<em>$1</em>');
// Strikethrough ~text~
s = s.replace(/~([^~]+)~/g, '<del>$1</del>');
// Auto-link URLs
s = s.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return s;
} catch (e) {
console.warn('formatMessageContent failed', e);
return escapeHtml(String(text));
}
}
window.formatMessageContent = formatMessageContent;
/**
* Descarga inmediata on-demand de un archivo multimedia pendiente.
* Se llama desde el botón del placeholder de media pendiente.
*/
window.forceDownloadMedia = async function(messageId, btnId, statusId) {
const btn = document.getElementById(btnId);
const statusEl = document.getElementById(statusId);
if (!btn) return;
// Mostrar estado de carga
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> Descargando...';
if (statusEl) statusEl.textContent = 'Solicitando descarga...';
try {
const resp = await fetch('api/force_download_media.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_id: messageId })
});
const data = await resp.json();
if (data.success) {
if (statusEl) statusEl.innerHTML = '<span style="color:#25d366">&#10003; Descargado. Recargando...</span>';
// Recargar los mensajes después de 1 segundo para mostrar el archivo
setTimeout(() => {
if (window.chatApp && window.chatApp.currentUserId) {
window.chatApp.loadMessages(window.chatApp.currentUserId, true, true);
}
}, 1200);
} else {
const msg = data.message || data.error || 'No se pudo descargar ahora';
if (statusEl) statusEl.innerHTML = '<span style="color:#e74c3c">⚠️ ' + escapeHtml(msg) + '</span>';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo"></i> Reintentar';
}
} catch (err) {
console.error('forceDownloadMedia error', err);
if (statusEl) statusEl.innerHTML = '<span style="color:#e74c3c">⚠️ Error de conexión</span>';
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-redo"></i> Reintentar';
}
};
// Delegate clicks on interactive buttons/list items to send quick replies
document.addEventListener('click', (ev) => {
const btn = ev.target.closest('.wa-interactive-btn');
if (btn) {
ev.stopPropagation();
const v = btn.dataset.value;
if (v && window.chatApp && typeof window.chatApp.sendQuickReply === 'function') {
window.chatApp.sendQuickReply(v);
}
}
const item = ev.target.closest('.wa-list-item');
if (item) {
ev.stopPropagation();
const v = item.dataset.value;
if (v && window.chatApp && typeof window.chatApp.sendQuickReply === 'function') {
window.chatApp.sendQuickReply(v);
}
}
});
// Helper para iconos de archivos (copiado de chat_window)
function getFileIcon(filename) {
if (!filename) return 'fas fa-file text-muted';
const ext = String(filename).split('.').pop().toLowerCase();
const iconMap = {
'pdf': 'fas fa-file-pdf text-danger',
'doc': 'fas fa-file-word text-primary',
'docx': 'fas fa-file-word text-primary',
'xls': 'fas fa-file-excel text-success',
'xlsx': 'fas fa-file-excel text-success',
'ppt': 'fas fa-file-powerpoint text-warning',
'pptx': 'fas fa-file-powerpoint text-warning',
'zip': 'fas fa-file-archive text-secondary',
'rar': 'fas fa-file-archive text-secondary',
'txt': 'fas fa-file-alt text-muted',
'csv': 'fas fa-file-csv text-success'
};
return iconMap[ext] || 'fas fa-file text-muted';
}
// Media lightbox helper (image/video with download)
function openMediaLightbox(url, type = 'image', caption = '') {
if (!url) {
showAlert && showAlert('Media no disponible', 'warning');
return;
}
const body = document.getElementById('imageLightboxBody');
const cap = document.getElementById('imageLightboxCaption');
const downloadBtn = document.getElementById('imageLightboxDownload');
if (!body || !cap) return;
// clear existing content
body.innerHTML = '';
// Decide rendering by type or by URL extension
const isVideo = (type === 'video') || /\.(mp4|webm|ogg)(\?|$)/i.test(url);
if (isVideo) {
body.innerHTML = `<video controls class="w-100" style="border-radius:10px;"><source src="${escapeHtml(url)}" type="video/mp4">Tu navegador no soporta video.</video>`;
} else {
body.innerHTML = `<img src="${escapeHtml(url)}" class="img-fluid w-100" style="border-radius:10px;">`;
}
cap.textContent = caption || '';
if (downloadBtn) {
downloadBtn.href = url;
downloadBtn.style.display = 'inline-block';
}
const modalEl = document.getElementById('imageLightboxModal');
const bsModal = new bootstrap.Modal(modalEl);
bsModal.show();
}
// backward compatibility wrapper
function openImageLightbox(url, caption = '') {
openMediaLightbox(url, 'image', caption);
}
// Inicializar cuando la página cargue
let chat;
document.addEventListener('DOMContentLoaded', () => {
chat = new WhatsAppChat();
window.chatApp = chat; // Exponer globalmente para funciones auxiliares
// Helpers para pruebas manuales y debugging
window.__showNoMoreMessagesHint = () => chat.showNoMoreMessagesHint && chat.showNoMoreMessagesHint();
window.__removeNoMoreMessagesHint = () => chat.removeNoMoreMessagesHint && chat.removeNoMoreMessagesHint();
});
</script>
<!-- Lightbox Modal -->
<div class="modal fade" id="imageLightboxModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 position-relative">
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Cerrar" style="z-index:10;"></button>
<div id="imageLightboxBody" style="max-height:75vh; overflow:auto;"></div>
<div id="imageLightboxCaption" class="mt-2 text-center text-white small"></div>
<div class="position-absolute bottom-0 end-0 m-3">
<a id="imageLightboxDownload" class="btn btn-sm btn-light" href="#" target="_blank" download style="display:none;"><i class="fas fa-download"></i> Descargar</a>
</div>
</div>
</div>
</div>
</div>
<!-- Reaction picker -->
<div id="reaction-picker" aria-hidden="true">
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<button class="emoji" data-emoji="👍">👍</button>
<button class="emoji" data-emoji="❤️">❤️</button>
<button class="emoji" data-emoji="😂">😂</button>
<button class="emoji" data-emoji="😮">😮</button>
<button class="emoji" data-emoji="😢">😢</button>
<button class="emoji" data-emoji="👏">👏</button>
<button class="emoji" data-emoji="🎉">🎉</button>
<button class="emoji" data-emoji="🔥">🔥</button>
<button class="emoji" data-emoji="🙏">🙏</button>
</div>
</div>
<!-- Modal para programar recordatorio -->
<div class="modal fade" id="reminderModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title">
<i class="fas fa-calendar-plus"></i> Programar Recordatorio
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Cliente</label>
<input type="text" class="form-control" id="reminder-user-name" readonly>
</div>
<div class="mb-3">
<label class="form-label">Fecha</label>
<input type="date" class="form-control" id="reminder-date" required>
</div>
<div class="mb-3">
<label class="form-label">Hora</label>
<input type="time" class="form-control" id="reminder-time" value="09:00" required>
</div>
<div class="mb-3">
<label class="form-label">Seleccionar Plantilla</label>
<select class="form-select" id="reminder-template" required>
<option value="">Cargando plantillas...</option>
</select>
</div>
<div id="reminder-variables-container" style="display:none;">
<label class="form-label">Variables de la Plantilla</label>
<div id="reminder-variables-fields"></div>
</div>
<div id="reminder-preview" class="mt-3" style="display:none;">
<h6 class="border-bottom pb-2">Vista Previa</h6>
<div id="reminder-preview-content" class="p-3 bg-light rounded"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancelar
</button>
<button type="button" class="btn btn-success" id="save-reminder-btn">
<i class="fas fa-save"></i> Programar Recordatorio
</button>
</div>
</div>
</div>
</div>
<!-- Modal para variables de plantilla (envío normal) -->
<div class="modal fade" id="templateVariablesModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">
<i class="fas fa-edit"></i> Variables de la Plantilla
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<strong id="template-modal-name"></strong>
</div>
<div id="template-variables-container">
<!-- Variables dinámicas -->
</div>
<div id="template-preview" class="mt-3" style="display:none;">
<h6 class="border-bottom pb-2">Vista Previa</h6>
<div id="template-preview-content" class="p-3 bg-light rounded"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancelar
</button>
<button type="button" class="btn btn-primary" id="send-template-with-vars-btn">
<i class="fas fa-paper-plane"></i> Enviar Plantilla
</button>
</div>
</div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════════════════════
MÓDULO LAB — Modal "Agendar Domicilio"
════════════════════════════════════════════════════════════════════════ -->
<style>
#modalAgendarDomicilio .addr-chip {
cursor:pointer; border:1px solid #0d6efd; color:#0d6efd;
background:#f0f4ff; border-radius:20px; padding:3px 10px;
font-size:.78rem; white-space:nowrap; transition:background .15s;
}
#modalAgendarDomicilio .addr-chip:hover { background:#0d6efd; color:#fff; }
#modalAgendarDomicilio .field-err { display:none; font-size:.8rem; color:#dc3545; margin-top:3px; }
/* ── Chips de archivos adjuntos (multi-orden) ── */
.labdom-orden-chip { position:relative; width:72px; flex-shrink:0; text-align:center; }
.labdom-orden-thumb { width:72px; height:72px; border:1px solid #dee2e6; border-radius:6px; background:#f8f9fa; display:flex; align-items:center; justify-content:center; overflow:hidden; }
.labdom-orden-name { display:block; font-size:.62rem; color:#666; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:72px; margin-top:3px; }
.labdom-orden-del { position:absolute; top:-5px; right:-5px; width:18px; height:18px; border-radius:50%; background:#dc3545; color:#fff; border:none; font-size:.7rem; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center; padding:0; }
.labdom-orden-del:hover { background:#c82333; }
#modalAgendarDomicilio .is-invalid ~ .field-err,
#modalAgendarDomicilio .is-invalid + .field-err { display:block; }
/* ── Barra de sesiones minimizadas ── */
#labdom-pills-bar {
position: fixed;
bottom: 0;
left: 0;
z-index: 1060;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 14px 8px;
pointer-events: none;
max-width: 80vw;
}
.labdom-pill {
pointer-events: all;
display: flex;
align-items: center;
gap: 5px;
background: linear-gradient(135deg, #1565c0, #0d47a1);
color: #fff;
border-radius: 24px;
padding: 5px 8px 5px 6px;
cursor: pointer;
font-size: .78rem;
box-shadow: 0 4px 16px rgba(0,0,0,.28);
max-width: 280px;
transition: background .15s, transform .1s, box-shadow .1s;
user-select: none;
animation: pillIn .18s ease;
}
.labdom-pill:hover { background: linear-gradient(135deg,#1976d2,#1565c0); transform: translateY(-2px); box-shadow: 0 6px 22px rgba(0,0,0,.32); }
@keyframes pillIn { from { transform:translateY(10px); opacity:0 } to { transform:translateY(0); opacity:1 } }
.labdom-pill-num {
background: rgba(255,255,255,.28);
border-radius: 50%;
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: .72rem; flex-shrink: 0;
}
.labdom-pill-body { display:flex; flex-direction:column; overflow:hidden; }
.labdom-pill-chat { font-weight: 600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width: 110px; font-size:.78rem; }
.labdom-pill-pac { font-size:.68rem; opacity:.85; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width: 110px; }
.labdom-pill-close {
background: rgba(255,255,255,.18);
border: none; color: #fff;
border-radius: 50%;
width: 17px; height: 17px;
display: flex; align-items:center; justify-content:center;
font-size: .6rem; cursor: pointer; padding: 0; flex-shrink: 0;
transition: background .12s;
line-height: 1;
}
.labdom-pill-close:hover { background: rgba(255,50,50,.55); }
/* Botón minimizar */
#labdom-btn-minimizar {
background: rgba(255,255,255,.18);
border: 1px solid rgba(255,255,255,.35);
color: #fff;
border-radius: 6px;
padding: 2px 8px;
font-size: .8rem;
line-height: 1.4;
cursor: pointer;
transition: background .15s;
}
#labdom-btn-minimizar:hover { background: rgba(255,255,255,.32); }
</style>
<div class="modal fade" id="modalAgendarDomicilio" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-primary text-white py-2">
<div>
<h5 class="modal-title mb-0"><i class="fas fa-house-medical me-2"></i>Agendar Domicilio</h5>
<small id="labdom-header-pac" class="opacity-75"></small>
</div>
<div class="d-flex align-items-center gap-2">
<button id="labdom-btn-minimizar" title="Minimizar — guarda el formulario y crea una píldora en la barra inferior"
onclick="labDomicilio.minimizar()">
<i class="fas fa-window-minimize"></i> Minimizar
</button>
<button class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<!-- ── PANEL ÉXITO (se muestra al guardar) ── -->
<div id="labdom-ok-panel" class="d-none">
<div class="text-center py-4 px-3">
<div class="mb-3" style="font-size:3rem">✅</div>
<h5 class="fw-bold text-success" id="labdom-ok-msg">Domicilio agendado</h5>
<p class="text-muted mb-4" id="labdom-ok-sub"></p>
<div class="d-flex justify-content-center gap-2 flex-wrap">
<a id="labdom-ok-asignar" href="lab_domicilios.php" target="_blank" class="btn btn-warning">
<i class="fas fa-user-nurse me-1"></i>Asignar enfermera
</a>
<a id="labdom-ok-ver" href="#" target="_blank" class="btn btn-outline-primary">
<i class="fas fa-external-link-alt me-1"></i>Ver domicilio
</a>
<button type="button" class="btn btn-success" onclick="labDomicilio.agendarOtro()">
<i class="fas fa-plus me-1"></i>Agendar otro
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
<!-- ── FORMULARIO ── -->
<div id="labdom-form-panel" class="modal-body pb-2">
<!-- Orden médica (múltiples archivos) -->
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-semibold small text-secondary"><i class="fas fa-paperclip me-1"></i>Órdenes médicas <span class="fw-normal text-muted">(opcional)</span></span>
<button type="button" id="labdom-btn-add-orden" class="btn btn-sm btn-outline-primary py-0 px-2" onclick="labDomicilio._agregarOrden()">
<i class="fas fa-plus me-1"></i>Agregar
</button>
</div>
<div id="labdom-ordenes-list" class="d-flex flex-wrap gap-2 mt-1"></div>
<input type="file" id="labdom-orden-input" accept="image/*,.pdf,.doc,.docx" multiple style="display:none">
</div>
<!-- ── TIPO (particular/seguro) — PRIMERO y llamativo ── -->
<div class="mb-3 p-2 rounded border bg-light">
<label class="fw-semibold small text-secondary mb-2 d-block">
<i class="fas fa-id-card me-1"></i>¿ES PARTICULAR O POR SEGURO?
</label>
<div class="d-flex gap-2">
<input type="radio" class="btn-check" name="labdom-tipo-cliente" id="tc-particular" value="particular" autocomplete="off"
onchange="labDomicilio._toggleSeguro()">
<label class="btn btn-sm btn-outline-secondary" for="tc-particular">
<i class="fas fa-wallet me-1"></i>Particular
</label>
<input type="radio" class="btn-check" name="labdom-tipo-cliente" id="tc-seguro" value="seguro" autocomplete="off"
onchange="labDomicilio._toggleSeguro()">
<label class="btn btn-sm btn-outline-info" for="tc-seguro">
<i class="fas fa-shield-alt me-1"></i>Seguro
</label>
</div>
<!-- Campos seguro (visibles solo cuando se elige Seguro) -->
<div id="labdom-seguro-row" class="mt-2 d-none">
<div class="mb-2">
<label class="form-label small mb-1">¿Qué seguro? <span class="text-danger">*</span></label>
<input type="text" class="form-control form-control-sm" id="labdom-seguro-nombre"
placeholder="Ej: Sura, Colsanitas, Compensar, Póliza SOAT…" autocomplete="off">
<div class="field-err" id="labdom-seguro-err">Indica el nombre del seguro</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small mb-1"><i class="fas fa-hashtag me-1 text-muted"></i>N.° de autorización <span class="text-muted">(opcional)</span></label>
<input type="text" class="form-control form-control-sm" id="labdom-autorizacion"
placeholder="Ej: 12345678" autocomplete="off">
</div>
<div class="col-6">
<label class="form-label small mb-1"><i class="fas fa-hand-holding-usd me-1 text-muted"></i>Copago asume el laboratorio <span class="text-muted">(opcional)</span></label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" min="0" step="1" class="form-control form-control-sm" id="labdom-copago"
placeholder="0">
</div>
</div>
</div>
</div>
</div>
<!-- ── PACIENTE ── -->
<div class="card border-0 bg-light mb-3 p-2">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-semibold small text-secondary"><i class="fas fa-user me-1"></i>PACIENTE</span>
<button id="labdom-btn-cambiar-pac" class="btn btn-xs btn-link text-primary p-0 small d-none" onclick="labDomicilio.limpiarPaciente()">Cambiar</button>
</div>
<!-- Buscador (visible cuando NO hay paciente seleccionado) -->
<div id="labdom-pac-buscar">
<div class="input-group input-group-sm">
<input type="text" id="labdom-pac-busq" class="form-control" placeholder="Buscar por nombre o documento…" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" onclick="labDomicilio.buscarPaciente()"><i class="fas fa-search"></i></button>
</div>
<div id="labdom-pac-list" class="list-group mt-1 shadow" style="position:relative;z-index:1060;max-height:165px;overflow-y:auto;display:none"></div>
<div class="mt-2 d-flex gap-2">
<button id="labdom-btn-contacto" class="btn btn-sm btn-outline-success flex-grow-1" type="button" onclick="labDomicilio.usarContacto()">
<i class="fab fa-whatsapp me-1"></i> Usar contacto
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="labDomicilio.crearNuevo()" title="Registrar paciente nuevo que no está en el sistema">
<i class="fas fa-user-plus me-1"></i>Nuevo
</button>
</div>
<div id="labdom-contacto-spin" class="text-center py-1 d-none">
<span class="spinner-border spinner-border-sm text-success"></span>
<span class="small text-muted ms-1">Buscando contacto…</span>
</div>
</div>
<!-- Tarjeta paciente seleccionado (visible cuando SÍ hay paciente) -->
<div id="labdom-pac-card" class="d-none">
<!-- Indicador modo nuevo paciente -->
<div id="labdom-nuevo-badge" class="d-none mb-2">
<span class="badge bg-warning text-dark"><i class="fas fa-user-plus me-1"></i>Nuevo paciente — se registrará al guardar</span>
</div>
<div class="d-flex align-items-start gap-2">
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center flex-shrink-0 mt-1" style="width:36px;height:36px;font-size:1rem">
<i class="fas fa-user"></i>
</div>
<div class="flex-grow-1">
<input type="text" class="form-control form-control-sm fw-semibold mb-1" id="labdom-pac-nombre" placeholder="Nombre completo *" autocomplete="off">
<div class="row g-1">
<div class="col-5">
<select class="form-select form-select-sm" id="labdom-pac-tipodoc" title="Tipo de documento">
<option value="CC">CC - Cédula</option>
<option value="TI">TI - Tarjeta</option>
<option value="CE">CE - Extran.</option>
<option value="PA">PA - Pasap.</option>
<option value="NIT">NIT</option>
<option value="RC">RC - Nac.</option>
</select>
</div>
<div class="col-7">
<input type="text" class="form-control form-control-sm" id="labdom-pac-cedula" placeholder="N.° documento" autocomplete="off">
</div>
</div>
</div>
</div>
<!-- Datos de contacto del paciente -->
<div id="labdom-pac-contacto" class="mt-2 d-none">
<div class="row g-1 small">
<div class="col-6">
<span class="text-muted"><i class="fas fa-phone me-1"></i></span>
<span id="labdom-pac-tel" class="fw-semibold">—</span>
</div>
<div class="col-6">
<span class="text-muted"><i class="fas fa-envelope me-1"></i></span>
<span id="labdom-pac-email" class="fw-semibold">—</span>
</div>
<div class="col-12" id="labdom-pac-eps-row" style="display:none">
<span class="text-muted small"><i class="fas fa-hospital me-1"></i>EPS: </span>
<span id="labdom-pac-eps" class="small fw-semibold"></span>
</div>
</div>
</div>
<!-- Historial de domicilios previos -->
<div id="labdom-hist-cont" class="mt-2 d-none">
<p class="mb-1 small text-muted"><i class="fas fa-history me-1"></i>Direcciones usadas anteriormente — toca para reusar:</p>
<div id="labdom-hist-chips" class="d-flex flex-wrap gap-1"></div>
</div>
<!-- Fecha de nacimiento y correo — editables, se pre-llenan si el paciente ya los tiene -->
<div class="mt-2 row g-2" id="labdom-pac-extra">
<div class="col-6">
<label class="form-label small mb-1"><i class="fas fa-birthday-cake me-1 text-muted"></i>Fecha de nacimiento</label>
<input type="date" class="form-control form-control-sm" id="labdom-pac-fnac">
</div>
<div class="col-6">
<label class="form-label small mb-1"><i class="fas fa-envelope me-1 text-muted"></i>Correo electrónico</label>
<input type="email" class="form-control form-control-sm" id="labdom-pac-correo" placeholder="correo@dominio.com">
</div>
</div>
</div>
</div>
<!-- ── FECHA · HORA · TIPO ── -->
<div class="row g-2 mb-3">
<div class="col-5">
<label class="form-label fw-semibold small mb-1">Fecha <span class="text-danger">*</span></label>
<input type="date" class="form-control form-control-sm" id="labdom-fecha">
<div class="field-err" id="labdom-fecha-err">Selecciona una fecha</div>
</div>
<div class="col-3">
<label class="form-label small mb-1">Hora</label>
<input type="time" class="form-control form-control-sm" id="labdom-hora">
</div>
<div class="col-4">
<label class="form-label small mb-1">Tipo</label>
<select class="form-select form-select-sm" id="labdom-tipo">
<option value="domicilio">🏠 Domicilio</option>
<option value="urgencia">🚨 Urgencia</option>
<option value="control">🔬 Control</option>
</select>
</div>
</div>
<!-- ── DIRECCIÓN ── -->
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">Dirección <span class="text-danger">*</span></label>
<input type="text" class="form-control form-control-sm" id="labdom-direccion" placeholder="Ej: Cra 15 # 32-10">
<div class="field-err" id="labdom-dir-err">Ingresa la dirección</div>
<div class="row g-2 mt-1">
<div class="col-6">
<input type="text" class="form-control form-control-sm" id="labdom-barrio" placeholder="Barrio">
</div>
<div class="col-6">
<input type="text" class="form-control form-control-sm" id="labdom-ciudad" placeholder="Ciudad">
</div>
<div class="col-12">
<input type="text" class="form-control form-control-sm" id="labdom-indicaciones" placeholder="Indicaciones: piso, apto, portero, referencias…">
</div>
</div>
</div>
<!-- ── EXÁMENES SOLICITADOS ── -->
<div class="mb-3">
<label class="form-label fw-semibold small mb-1">
<i class="fas fa-flask me-1 text-primary"></i>Exámenes solicitados
<span class="badge bg-secondary ms-1" style="font-size:.65rem">si no hay foto de orden</span>
</label>
<textarea class="form-control form-control-sm" id="labdom-examenes" rows="2"
placeholder="Ej: Hemograma completo, glucosa en ayunas, parcial de orina…"></textarea>
<div class="form-text text-muted" style="font-size:.73rem">
<i class="fas fa-info-circle me-1"></i>Si el cliente envió foto de la orden, este campo es opcional.
</div>
</div>
<!-- ── INFORMACIÓN DE COBRO ── -->
<div class="mb-3 p-2 rounded border bg-light">
<label class="fw-semibold small text-secondary mb-2 d-block">
<i class="fas fa-file-invoice-dollar me-1"></i>Información de cobro <span class="fw-normal text-muted">(opcional)</span>
</label>
<div class="row g-2">
<div class="col-6">
<label class="form-label small mb-1">Valor del domicilio</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" min="0" step="1" class="form-control form-control-sm" id="labdom-valor-domicilio"
placeholder="0" oninput="labDomicilio._calcTotal()">
</div>
</div>
<div class="col-6">
<label class="form-label small mb-1">Valor del copago</label>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" min="0" step="1" class="form-control form-control-sm" id="labdom-valor-copago"
placeholder="0" oninput="labDomicilio._calcTotal()">
</div>
</div>
<!-- Total a cobrar al cliente -->
<div class="col-12" id="labdom-total-row" style="display:none">
<div class="d-flex align-items-center justify-content-between rounded px-3 py-2 mt-1"
style="background:#d1fae5;border:1px solid #6ee7b7">
<span class="fw-semibold small text-success">
<i class="fas fa-cash-register me-1"></i>Total a cobrar al cliente
</span>
<span class="fw-bold text-success" id="labdom-total-valor" style="font-size:1rem">$0</span>
</div>
</div>
</div>
</div>
<!-- ── NOTAS ADMIN ── -->
<div class="mb-2">
<label class="form-label small mb-1"><i class="fas fa-sticky-note me-1"></i>Notas internas del agendamiento</label>
<textarea class="form-control form-control-sm" id="labdom-notas" rows="2"
placeholder="Indicaciones especiales, observaciones del operador…"></textarea>
</div>
</div><!-- /form-panel -->
<div id="labdom-footer" class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary btn-sm" id="labdom-btn-guardar" onclick="labDomicilio.guardar()">
<i class="fas fa-calendar-check me-1"></i> Agendar Domicilio
</button>
</div>
</div>
</div>
</div>
<script>
// ══════════════════════════════════════════════════════════════════════════════
// Módulo Lab — Agendar Domicilio desde conversations.php
// Soporta múltiples domicilios al mismo paciente en la misma sesión.
// ══════════════════════════════════════════════════════════════════════════════
const labDomicilio = (() => {
// Estado interno
let _msgId = null;
let _localFile = null;
let _newOrderFiles = []; // archivos nuevos seleccionados por el usuario
let _newOrderDataUrls = []; // data-URLs para previsualizar (índice paralelo)
let _localFiles = []; // filenames ya en servidor (del mensaje WA original)
let _convId = null;
let _pacienteId = null;
let _isNewPaciente = false; // true = paciente nuevo (no existe en BD aún)
let _pacNombre = '';
let _histDirs = []; // direcciones únicas previas del paciente
let _bsModal = null;
// Sesiones minimizadas (múltiples chats en paralelo)
let _sesiones = []; // [{ id, chatUserId, chatName, chatPhone, convId, pacienteId, pacNombre, histDirs, msgId, localFile, cardVisible, pacInfo, fields }]
let _sesionIdCounter = 0;
// ── helpers DOM ──────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const fv = id => $( id).value.trim();
function fechaManana() {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.toISOString().slice(0, 10);
}
function mostrarFormulario() {
$('labdom-ok-panel').classList.add('d-none');
$('labdom-form-panel').style.display = '';
$('labdom-footer').style.display = '';
}
function limpiarValidacion() {
['labdom-fecha','labdom-direccion','labdom-pac-nombre','labdom-pac-cedula','labdom-pac-correo'].forEach(id => {
const el = $(id); if (el) el.classList.remove('is-invalid');
});
}
function validar() {
let ok = true;
limpiarValidacion();
if (!fv('labdom-fecha')) { $('labdom-fecha').classList.add('is-invalid'); ok = false; }
if (!fv('labdom-direccion')){ $('labdom-direccion').classList.add('is-invalid'); ok = false; }
// Validar formato de dirección
const dirVal = fv('labdom-direccion') || '';
if (dirVal && dirVal.trim().length < 5) {
$('labdom-direccion').classList.add('is-invalid'); ok = false;
}
const esSeguro = document.getElementById('tc-seguro')?.checked;
if (esSeguro && !fv('labdom-seguro-nombre')) {
$('labdom-seguro-nombre').classList.add('is-invalid');
ok = false;
}
// Validaciones de paciente nuevo
if (_isNewPaciente) {
const nom = fv('labdom-pac-nombre') || '';
if (!nom) { $('labdom-pac-nombre').classList.add('is-invalid'); ok = false; }
else if (!/^[\p{L}\s'\-\.]+$/u.test(nom)) { $('labdom-pac-nombre').classList.add('is-invalid'); ok = false; }
else if (nom.trim().split(/\s+/).length < 2) { $('labdom-pac-nombre').classList.add('is-invalid'); ok = false; }
const ced = fv('labdom-pac-cedula') || '';
if (ced) {
const tipodoc = $('labdom-pac-tipodoc')?.value || 'CC';
if (['CC','TI','RC','CE'].includes(tipodoc)) {
const digits = ced.replace(/[^0-9]/g, '');
if (digits.length < 4 || digits.length > 12) { $('labdom-pac-cedula').classList.add('is-invalid'); ok = false; }
}
}
const cor = fv('labdom-pac-correo') || '';
if (cor && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cor)) { $('labdom-pac-correo').classList.add('is-invalid'); ok = false; }
}
return ok;
}
// ── cargar historial de direcciones del paciente ─────────────────────────
async function cargarHistorial(pacienteId) {
$('labdom-hist-cont').classList.add('d-none');
$('labdom-hist-chips').innerHTML = '';
_histDirs = [];
try {
const r = await fetch(`api/lab/get_domicilios.php?paciente_id=${pacienteId}&limit=30&no_stats=1`);
const d = await r.json();
if (!d.data?.length) return;
// Deduplicar por dirección+barrio, mantener las más recientes primero
const vistas = new Set();
const dirs = [];
for (const dom of d.data) {
const key = (dom.direccion||'').toLowerCase().trim();
if (!key || vistas.has(key)) continue;
vistas.add(key);
dirs.push({
direccion: dom.direccion || '',
barrio: dom.barrio || '',
ciudad: dom.ciudad || '',
indicaciones_dir: dom.indicaciones_dir || '',
});
if (dirs.length >= 4) break; // máximo 4 chips
}
if (!dirs.length) return;
_histDirs = dirs;
const chips = $('labdom-hist-chips');
dirs.forEach((dir, i) => {
const label = dir.barrio ? `${dir.direccion} (${dir.barrio})` : dir.direccion;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'addr-chip';
btn.title = [dir.direccion, dir.barrio, dir.ciudad].filter(Boolean).join(' · ');
btn.innerHTML = `<i class="fas fa-map-marker-alt me-1"></i>${escLab(label)}`;
btn.onclick = () => labDomicilio.usarDireccion(i);
chips.appendChild(btn);
});
$('labdom-hist-cont').classList.remove('d-none');
} catch (_) { /* silencioso */ }
}
// ── API pública ──────────────────────────────────────────────────────────
return {
/* Abre el modal desde el botón del mensaje imagen */
abrir(msgId, localFile, convId) {
_msgId = msgId || null;
_localFiles = localFile ? [localFile] : [];
_newOrderFiles = [];
_newOrderDataUrls = [];
_convId = convId ? parseInt(convId) : null;
// Guardar también el userId actual para usarContacto cuando convId no esté disponible
if (!_convId && typeof chatApp !== 'undefined' && chatApp.currentUserId) {
_convId = '__user:' + chatApp.currentUserId;
}
mostrarFormulario();
labDomicilio._resetFull();
labDomicilio._updateOrdenPreview();
if (!_bsModal) _bsModal = new bootstrap.Modal('#modalAgendarDomicilio', { backdrop: true, keyboard: true });
_bsModal.show();
// Pre-cargar contacto de la conversación de forma silenciosa
if (_convId) labDomicilio.usarContacto();
},
/* Reset completo (nombre paciente + todos los campos) */
_resetFull() {
_pacienteId = null;
_pacNombre = '';
_histDirs = [];
_newOrderFiles = [];
_newOrderDataUrls = [];
_localFiles = [];
$('labdom-pac-busq').value = '';
$('labdom-pac-list').style.display = 'none';
$('labdom-pac-buscar').classList.remove('d-none');
$('labdom-pac-card').classList.add('d-none');
$('labdom-btn-cambiar-pac').classList.add('d-none');
$('labdom-pac-contacto').classList.add('d-none');
$('labdom-hist-cont').classList.add('d-none');
$('labdom-hist-chips').innerHTML = '';
$('labdom-header-pac').textContent = '';
if ($('labdom-pac-nombre')) $('labdom-pac-nombre').value = '';
if ($('labdom-pac-cedula')) $('labdom-pac-cedula').value = '';
if ($('labdom-pac-tipodoc')) $('labdom-pac-tipodoc').value = 'CC';
_isNewPaciente = false;
if ($('labdom-nuevo-badge')) $('labdom-nuevo-badge').classList.add('d-none');
// Resetear tipo cliente a "particular" por defecto
const tc = document.getElementById('tc-particular');
if (tc) tc.checked = true;
labDomicilio._toggleSeguro();
labDomicilio._resetDomicilio();
},
/* Renderiza la lista de archivos de la orden médica */
_updateOrdenPreview() {
const list = $('labdom-ordenes-list');
if (!list) return;
const iconCls = ext => {
const m = { pdf:'fas fa-file-pdf text-danger', doc:'fas fa-file-word text-primary', docx:'fas fa-file-word text-primary', mp4:'fas fa-file-video text-warning', mkv:'fas fa-file-video text-warning', mp3:'fas fa-file-audio text-success', ogg:'fas fa-file-audio text-success', webm:'fas fa-file-video text-warning' };
return m[ext] || 'fas fa-file text-secondary';
};
const isImgExt = ext => /^(jpg|jpeg|png|gif|webp|bmp)$/.test(ext);
const chip = (label, thumb, delCall) => `<div class="labdom-orden-chip" title="${escLab(label)}">
<div class="labdom-orden-thumb">${thumb}</div>
<span class="labdom-orden-name">${escLab(label.length > 13 ? label.slice(0,10)+'\u2026' : label)}</span>
<button type="button" class="labdom-orden-del" onclick="${delCall}" title="Eliminar">&times;</button>
</div>`;
let html = '';
_localFiles.forEach((fname, i) => {
const ext = (fname||'').split('.').pop().toLowerCase();
const thumb = isImgExt(ext)
? `<img src="uploads/media/${escLab(fname)}" style="width:100%;height:100%;object-fit:cover;border-radius:4px">`
: `<i class="${iconCls(ext)}" style="font-size:1.6rem"></i>`;
html += chip(fname, thumb, `labDomicilio._eliminarOrden('local',${i})`);
});
_newOrderFiles.forEach((file, i) => {
const ext = (file.name||'').split('.').pop().toLowerCase();
const dUrl = _newOrderDataUrls[i] || '';
const thumb = (isImgExt(ext) && dUrl)
? `<img src="${dUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:4px">`
: `<i class="${iconCls(ext)}" style="font-size:1.6rem"></i>`;
html += chip(file.name, thumb, `labDomicilio._eliminarOrden('new',${i})`);
});
list.innerHTML = html;
},
/* Abre el selector de archivo (soporta múltiples) y los agrega a la lista */
_agregarOrden() {
const inp = document.getElementById('labdom-orden-input');
if (!inp) return;
const handler = (e) => {
inp.removeEventListener('change', handler);
const files = e.target.files ? Array.from(e.target.files) : [];
if (!files.length) return;
let pending = files.length;
files.forEach(file => {
const ext = (file.name||'').split('.').pop().toLowerCase();
const isImage = /^(jpg|jpeg|png|gif|webp|bmp)$/.test(ext);
if (isImage) {
const reader = new FileReader();
reader.onload = ev => {
_newOrderFiles.push(file);
_newOrderDataUrls.push(ev.target.result);
if (--pending === 0) labDomicilio._updateOrdenPreview();
};
reader.readAsDataURL(file);
} else {
_newOrderFiles.push(file);
_newOrderDataUrls.push('');
if (--pending === 0) labDomicilio._updateOrdenPreview();
}
});
inp.value = '';
};
inp.addEventListener('change', handler);
inp.click();
},
/* Elimina un archivo específico de la lista */
_eliminarOrden(type, idx) {
if (type === 'local') {
_localFiles.splice(idx, 1);
if (!_localFiles.length && !_newOrderFiles.length) _msgId = null;
} else {
_newOrderFiles.splice(idx, 1);
_newOrderDataUrls.splice(idx, 1);
}
labDomicilio._updateOrdenPreview();
},
/* Muestra/oculta el campo «nombre del seguro» según la selección */
_toggleSeguro() {
const esSeguro = document.getElementById('tc-seguro')?.checked;
const row = $('labdom-seguro-row');
if (!row) return;
if (esSeguro) {
row.classList.remove('d-none');
} else {
row.classList.add('d-none');
if ($('labdom-seguro-nombre')) $('labdom-seguro-nombre').value = '';
}
},
/* Calcula y muestra el total a cobrar al cliente */
_calcTotal() {
const dom = parseFloat($('labdom-valor-domicilio')?.value) || 0;
const copago = parseFloat($('labdom-valor-copago')?.value) || 0;
const total = dom + copago;
const row = $('labdom-total-row');
const label = $('labdom-total-valor');
if (!row || !label) return;
if (total > 0) {
label.textContent = '$' + total.toLocaleString('es-CO');
row.style.display = '';
} else {
row.style.display = 'none';
}
},
/* Reset solo los campos del domicilio (mantiene paciente) */
_resetDomicilio() {
$('labdom-fecha').value = fechaManana();
$('labdom-hora').value = '07:00';
$('labdom-tipo').value = 'domicilio';
$('labdom-direccion').value = '';
$('labdom-barrio').value = '';
$('labdom-ciudad').value = '';
$('labdom-indicaciones').value= '';
$('labdom-examenes').value = '';
$('labdom-notas').value = '';
if ($('labdom-seguro-nombre')) $('labdom-seguro-nombre').value = '';
if ($('labdom-autorizacion')) $('labdom-autorizacion').value = '';
if ($('labdom-copago')) $('labdom-copago').value = '';
if ($('labdom-valor-domicilio')) $('labdom-valor-domicilio').value = '';
if ($('labdom-valor-copago')) $('labdom-valor-copago').value = '';
labDomicilio._calcTotal();
limpiarValidacion();
},
/* ── Buscar paciente ── */
async buscarPaciente() {
const q = fv('labdom-pac-busq');
if (!q) return;
const r = await fetch(`api/lab/get_pacientes.php?busqueda=${encodeURIComponent(q)}&limit=8`);
const d = await r.json();
const list = $('labdom-pac-list');
if (!d.data?.length) {
list.innerHTML = '<a class="list-group-item list-group-item-action text-muted disabled small py-2">Sin resultados</a>';
list.style.display = 'block';
return;
}
list.innerHTML = d.data.map(p =>
`<button type="button" class="list-group-item list-group-item-action py-2"
data-pid="${p.id}"
data-nombre="${escAtr(p.nombre_completo)}"
data-dir="${escAtr(p.direccion||'')}"
data-ciudad="${escAtr(p.ciudad||'')}"
data-info="${escAtr((p.tipo_documento||'') + ' ' + (p.numero_documento||''))}"
data-tipodoc="${escAtr(p.tipo_documento||'')}"
data-numdoc="${escAtr(p.numero_documento||'')}"
data-tel="${escAtr(p.telefono||'')}"
data-email="${escAtr(p.email||'')}"
data-eps="${escAtr(p.eps||'')}"
data-fnac="${escAtr(p.fecha_nacimiento||'')}"
onclick="labDomicilio._clickPaciente(this)">
<strong class="small">${escLab(p.nombre_completo)}</strong>
<span class="text-muted ms-2 small">${escLab(p.tipo_documento||'')} ${escLab(p.numero_documento||'')}</span>
${p.telefono ? `<span class="ms-2 small text-success"><i class="fas fa-phone me-1"></i>${escLab(p.telefono)}</span>` : ''}
${p.direccion ? `<br><small class="text-primary"><i class="fas fa-map-marker-alt me-1"></i>${escLab(p.direccion)}</small>` : ''}
</button>`
).join('');
list.style.display = 'block';
},
_clickPaciente(btn) {
labDomicilio.selPaciente(
+btn.dataset.pid,
btn.dataset.nombre,
btn.dataset.dir,
btn.dataset.ciudad,
btn.dataset.info,
btn.dataset.tel || '',
btn.dataset.email || '',
btn.dataset.eps || '',
btn.dataset.fnac || '',
btn.dataset.tipodoc || '',
btn.dataset.numdoc || ''
);
},
selPaciente(id, nombre, direccion, ciudad, info, tel = '', email = '', eps = '', fechaNac = '', tipoDoc = '', numDoc = '') {
_pacienteId = id;
_pacNombre = nombre;
// Extraer tipo/num del campo 'info' como fallback si no se pasan explícitamente
const _infoMatch = (info||'').match(/^([A-Z]+)\s*(.*)/);
const _tipoFinal = tipoDoc || (_infoMatch?.[1] || 'CC');
const _numFinal = numDoc || (_infoMatch?.[2]?.trim() || '');
// Ocultar buscador, mostrar tarjeta
$('labdom-pac-list').style.display = 'none';
$('labdom-pac-buscar').classList.add('d-none');
$('labdom-pac-card').classList.remove('d-none');
$('labdom-btn-cambiar-pac').classList.remove('d-none');
if ($('labdom-pac-nombre')) $('labdom-pac-nombre').value = nombre;
if ($('labdom-pac-cedula')) $('labdom-pac-cedula').value = _numFinal;
if ($('labdom-pac-tipodoc')) $('labdom-pac-tipodoc').value = _tipoFinal;
$('labdom-header-pac').textContent = nombre;
// Mostrar datos de contacto
if (tel || email) {
$('labdom-pac-tel').textContent = tel || '—';
$('labdom-pac-email').textContent = email || '—';
if (eps) {
$('labdom-pac-eps').textContent = eps;
$('labdom-pac-eps-row').style.display = '';
} else {
$('labdom-pac-eps-row').style.display = 'none';
}
$('labdom-pac-contacto').classList.remove('d-none');
} else {
$('labdom-pac-contacto').classList.add('d-none');
}
// Pre-llenar campos solo si están vacíos
if (direccion && !fv('labdom-direccion')) $('labdom-direccion').value = direccion;
if (ciudad && !fv('labdom-ciudad')) $('labdom-ciudad').value = ciudad;
// Pre-llenar fecha de nacimiento y correo desde el paciente
if ($('labdom-pac-fnac')) $('labdom-pac-fnac').value = fechaNac || '';
if ($('labdom-pac-correo')) $('labdom-pac-correo').value = email || '';
// Cargar historial de domicilios previos
cargarHistorial(id);
},
limpiarPaciente() {
_pacienteId = null;
_isNewPaciente = false;
_pacNombre = '';
$('labdom-pac-buscar').classList.remove('d-none');
$('labdom-pac-card').classList.add('d-none');
$('labdom-btn-cambiar-pac').classList.add('d-none');
$('labdom-pac-busq').value = '';
$('labdom-pac-contacto').classList.add('d-none');
$('labdom-hist-cont').classList.add('d-none');
$('labdom-header-pac').textContent = '';
if ($('labdom-nuevo-badge')) $('labdom-nuevo-badge').classList.add('d-none');
if ($('labdom-pac-fnac')) $('labdom-pac-fnac').value = '';
if ($('labdom-pac-correo')) $('labdom-pac-correo').value = '';
if ($('labdom-pac-nombre')) $('labdom-pac-nombre').value = '';
if ($('labdom-pac-cedula')) $('labdom-pac-cedula').value = '';
if ($('labdom-pac-tipodoc')) $('labdom-pac-tipodoc').value = 'CC';
// Mantener dirección/notas para no perder lo escrito
},
/* Modo nuevo paciente: oculta el buscador y muestra la tarjeta vacía para registrar */
crearNuevo() {
_pacienteId = null;
_isNewPaciente = true;
_pacNombre = '';
_histDirs = [];
$('labdom-pac-buscar').classList.add('d-none');
$('labdom-pac-card').classList.remove('d-none');
$('labdom-btn-cambiar-pac').classList.remove('d-none');
$('labdom-pac-contacto').classList.add('d-none');
$('labdom-hist-cont').classList.add('d-none');
$('labdom-pac-eps-row') && ($('labdom-pac-eps-row').style.display = 'none');
if ($('labdom-nuevo-badge')) $('labdom-nuevo-badge').classList.remove('d-none');
// Limpiar campos del paciente
if ($('labdom-pac-nombre')) { $('labdom-pac-nombre').value = ''; $('labdom-pac-nombre').focus(); }
if ($('labdom-pac-cedula')) $('labdom-pac-cedula').value = '';
if ($('labdom-pac-tipodoc')) $('labdom-pac-tipodoc').value = 'CC';
if ($('labdom-pac-fnac')) $('labdom-pac-fnac').value = '';
if ($('labdom-pac-correo')) $('labdom-pac-correo').value = '';
$('labdom-header-pac').textContent = 'Nuevo paciente';
},
/* Aplica una dirección histórica al formulario */
usarDireccion(idx) {
const dir = _histDirs[idx];
if (!dir) return;
$('labdom-direccion').value = dir.direccion;
$('labdom-barrio').value = dir.barrio;
$('labdom-ciudad').value = dir.ciudad;
$('labdom-indicaciones').value = dir.indicaciones_dir;
limpiarValidacion();
// Resaltar brevemente el campo
$('labdom-direccion').classList.add('border-primary');
setTimeout(() => $('labdom-direccion').classList.remove('border-primary'), 1200);
},
/* Contacto de WhatsApp → paciente automático */
async usarContacto() {
// Obtener convId o userId del contexto actual
let effectiveConvId = _convId;
if (!effectiveConvId && typeof chatApp !== 'undefined' && chatApp.currentUserId) {
effectiveConvId = '__user:' + chatApp.currentUserId;
}
if (!effectiveConvId) return;
const spin = $('labdom-contacto-spin');
const btn = $('labdom-btn-contacto');
try {
spin.classList.remove('d-none');
btn.classList.add('d-none');
// Determinar si es user_id o conversation_id
let qparam;
if (String(effectiveConvId).startsWith('__user:')) {
qparam = `user_id=${String(effectiveConvId).slice(7)}`;
} else {
qparam = `conversation_id=${effectiveConvId}`;
}
const r = await fetch(`api/lab/crear_desde_whatsapp.php?solo_paciente=1&${qparam}`);
const d = await r.json();
if (d.success && d.paciente) {
labDomicilio.selPaciente(
d.paciente.id,
d.paciente.nombre_completo,
d.paciente.direccion || '',
d.paciente.ciudad || '',
[(d.paciente.tipo_documento||''), (d.paciente.numero_documento||'')].filter(Boolean).join(' '),
d.paciente.telefono || '',
d.paciente.email || '',
d.paciente.eps || '',
d.paciente.fecha_nacimiento || '',
d.paciente.tipo_documento || '',
d.paciente.numero_documento || ''
);
return;
}
} catch (_) { /* silencioso */ } finally {
spin.classList.add('d-none');
if (!_pacienteId) btn.classList.remove('d-none');
}
},
/* ── Guardar domicilio ── */
async guardar() {
// Validar paciente
if (!_pacienteId && !_isNewPaciente) {
$('labdom-pac-busq')?.focus();
$('labdom-pac-busq')?.classList.add('is-invalid');
setTimeout(() => $('labdom-pac-busq')?.classList.remove('is-invalid'), 2000);
return;
}
if (_isNewPaciente) {
const nom = fv('labdom-pac-nombre');
if (!nom) {
$('labdom-pac-nombre').classList.add('is-invalid');
$('labdom-pac-nombre').focus();
setTimeout(() => $('labdom-pac-nombre').classList.remove('is-invalid'), 2200);
return;
}
}
if (!validar()) return;
const btn = $('labdom-btn-guardar');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Guardando…';
// ── Crear paciente nuevo si aplica ────────────────────────────
if (_isNewPaciente) {
try {
const tipodoc = $('labdom-pac-tipodoc')?.value || 'CC';
const cedula = fv('labdom-pac-cedula');
const fnac = fv('labdom-pac-fnac');
const correo = fv('labdom-pac-correo');
const payload = {
nombre_completo: fv('labdom-pac-nombre'),
tipo_documento: tipodoc,
};
if (cedula) payload.numero_documento = cedula;
if (fnac) payload.fecha_nacimiento = fnac;
if (correo) payload.email = correo;
const r = await fetch('api/lab/save_paciente.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const d = await r.json();
if (d.success && d.id) {
_pacienteId = d.id;
_isNewPaciente = false;
if ($('labdom-nuevo-badge')) $('labdom-nuevo-badge').classList.add('d-none');
} else {
const errDiv = document.createElement('div');
errDiv.className = 'alert alert-danger alert-dismissible mt-2 py-2';
errDiv.innerHTML = `<i class="fas fa-exclamation-circle me-1"></i>${escLab(d.error||'No se pudo registrar el paciente')}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
$('labdom-form-panel').prepend(errDiv);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i> Agendar Domicilio';
return;
}
} catch (err) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i> Agendar Domicilio';
return;
}
}
const datos = {
paciente_id: _pacienteId,
fecha_programada: fv('labdom-fecha'),
hora_programada: fv('labdom-hora') || null,
tipo_servicio: fv('labdom-tipo'),
tipo_cliente: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value) || 'particular',
seguro_nombre: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value === 'seguro') ? (fv('labdom-seguro-nombre') || null) : null,
autorizacion: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value === 'seguro') ? (fv('labdom-autorizacion') || null) : null,
copago_laboratorio: (document.querySelector('input[name="labdom-tipo-cliente"]:checked')?.value === 'seguro') ? ($('labdom-copago').value || null) : null,
valor_domicilio: $('labdom-valor-domicilio').value || null,
valor_copago: $('labdom-valor-copago').value || null,
examenes_solicitados: fv('labdom-examenes') || null,
direccion: fv('labdom-direccion'),
barrio: fv('labdom-barrio') || null,
ciudad: fv('labdom-ciudad') || null,
indicaciones_dir: fv('labdom-indicaciones') || null,
notas_admin: fv('labdom-notas') || null,
};
// ── Subir archivos nuevos de orden ─────────────────────────────
for (const file of _newOrderFiles) {
try {
const fd = new FormData();
fd.append('file', file);
const upRes = await fetch('api/lab/upload_orden.php', { method: 'POST', body: fd });
const upData = await upRes.json();
if (upData.success && upData.local_file) _localFiles.push(upData.local_file);
} catch (_e) { /* silencioso */ }
}
_newOrderFiles = [];
_newOrderDataUrls = [];
// ── Crear registro de orden por cada archivo ───────────────────
if (_localFiles.length) {
try {
const convIdNum = (_convId && !String(_convId).startsWith('__user:'))
? parseInt(_convId) : null;
const allOrdenIds = [];
for (let _fi = 0; _fi < _localFiles.length; _fi++) {
const orRes = await fetch('api/lab/crear_desde_whatsapp.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paciente_id: _pacienteId,
local_file: _localFiles[_fi],
media_message_id: _fi === 0 ? (_msgId || null) : null,
conversation_id: convIdNum,
examenes_solicitados: datos.examenes_solicitados,
estado: 'pendiente',
}),
});
const orData = await orRes.json();
if (orData.success && orData.orden_id) {
allOrdenIds.push(orData.orden_id);
if (_fi === 0) datos.orden_id = orData.orden_id;
}
}
datos._allOrdenIds = allOrdenIds;
} catch (_) { /* no bloquea */ }
}
try {
const r = await fetch('api/lab/save_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
const d = await r.json();
if (d.success) {
// Vincular todas las órdenes al domicilio recién creado
const _oids = datos._allOrdenIds;
if (Array.isArray(_oids) && _oids.length > 0 && d.id) {
fetch('api/lab/link_ordenes_domicilio.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domicilio_id: d.id, orden_ids: _oids }),
}).catch(() => {});
}
// Actualizar _pacNombre si el usuario editó el nombre en el input
const _editedNombre = fv('labdom-pac-nombre');
if (_editedNombre) _pacNombre = _editedNombre;
// Actualizar datos del paciente (nombre, cédula, fecha nacimiento, correo)
const updFnac = fv('labdom-pac-fnac');
const updCorreo = fv('labdom-pac-correo');
const updNombre = fv('labdom-pac-nombre');
const updCedula = fv('labdom-pac-cedula');
const updTipodoc = $('labdom-pac-tipodoc')?.value || '';
if (_pacienteId && (updFnac || updCorreo || updNombre || updCedula)) {
const patch = { id: _pacienteId };
if (updFnac) patch.fecha_nacimiento = updFnac;
if (updCorreo) patch.email = updCorreo;
if (updNombre) patch.nombre_completo = updNombre;
if (updCedula) patch.numero_documento = updCedula;
if (updTipodoc) patch.tipo_documento = updTipodoc;
fetch('api/lab/save_paciente.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).catch(() => {}); // silencioso — no bloquea el flujo
}
// Mostrar panel de éxito DENTRO del modal (no cierra)
$('labdom-form-panel').style.display = 'none';
$('labdom-footer').style.display = 'none';
const horaStr = datos.hora_programada ? ` a las ${datos.hora_programada}` : '';
$('labdom-ok-msg').textContent = `✅ Domicilio #${d.id} agendado`;
const tipoLabel = { particular:'👤 Particular', seguro:'🛡️ Seguro' };
if (datos.tipo_cliente === 'seguro' && datos.seguro_nombre) tipoLabel.seguro = `🛡️ Seguro — ${datos.seguro_nombre}`;
const examStr = datos.examenes_solicitados ? `<br><small class="text-muted">${escLab(datos.examenes_solicitados)}</small>` : '';
$('labdom-ok-sub').innerHTML =
`<strong>${escLab(_pacNombre)}</strong> · <span class="badge bg-secondary">${tipoLabel[datos.tipo_cliente]||datos.tipo_cliente}</span><br>${escLab(datos.direccion)}${examStr}<br><span class="badge bg-primary">${datos.fecha_programada}${horaStr}</span>`;
$('labdom-ok-ver').href = `lab_domicilios.php?id=${d.id}`;
$('labdom-ok-asignar').href = `lab_domicilios.php?id=${d.id}`;
$('labdom-ok-panel').classList.remove('d-none');
// Actualizar historial (puede haber nueva dirección)
if (_pacienteId) cargarHistorial(_pacienteId);
} else {
// Error inline
const errDiv = document.createElement('div');
errDiv.className = 'alert alert-danger alert-dismissible mt-2 py-2';
errDiv.innerHTML = `<i class="fas fa-exclamation-circle me-1"></i>${escLab(d.error||'Error al guardar')}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
$('labdom-form-panel').prepend(errDiv);
}
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i> Agendar Domicilio';
}
},
/* "Agendar otro" → mantiene paciente, resetea solo el domicilio */
agendarOtro() {
mostrarFormulario();
labDomicilio._resetDomicilio();
// Si hay historial, enfocar notas (la dirección ya tiene chips para elegir)
if (_histDirs.length) {
$('labdom-notas').focus();
} else {
$('labdom-direccion').focus();
}
},
/* Cierra el modal de citas si está visible y llama cb() al terminar */
_cerrarCitas(cb) {
const elC = document.getElementById('modalCitasPaciente');
if (elC && elC.classList.contains('show')) {
const inst = bootstrap.Modal.getInstance(elC);
if (inst) {
elC.addEventListener('hidden.bs.modal', cb, { once: true });
inst.hide();
return;
}
}
cb();
},
/* Abrir modal sin imagen (botón header o "nueva cita" desde citasModal) */
abrirRapido() {
// currentConversationId no siempre está disponible; usamos currentUserId como fallback
const convId = (typeof chatApp !== 'undefined' && chatApp.currentConversationId)
? chatApp.currentConversationId : 0;
this._cerrarCitas(() => labDomicilio.abrir(0, '', convId));
},
/* Abrir modal y pre-rellenar dirección detectada en un mensaje */
abrirConTexto(convId, address) {
this._cerrarCitas(() => {
labDomicilio.abrir(0, '', convId);
setTimeout(() => {
const inp = document.getElementById('labdom-direccion');
if (inp && address) { inp.value = address; inp.dispatchEvent(new Event('input')); }
}, 140);
});
},
/* Usar una dirección del historial de citas (desde citasModal) */
_usarDirHistorial(dir) {
this._cerrarCitas(() => {
const inp = document.getElementById('labdom-direccion');
if (inp) { inp.value = dir; inp.dispatchEvent(new Event('input')); }
const el = document.getElementById('modalAgendarDomicilio');
if (el) bootstrap.Modal.getOrCreateInstance(el).show();
});
},
/* Devuelve el ID del paciente actualmente seleccionado */
getPacienteId() { return _pacienteId || 0; },
getPacienteNombre() {
const el = document.getElementById('labdom-pac-nombre');
return el ? (el.value !== undefined ? el.value.trim() : el.textContent.trim()) : '';
},
/* ─────────────────────────────────────────────────────────────────
* SISTEMA DE SESIONES MINIMIZADAS
* Al minimizar: guarda estado del form en memoria y muestra una
* píldora en la barra inferior con el nombre del chat + paciente.
* Al hacer clic en la píldora: navega al chat y restaura el form.
* ───────────────────────────────────────────────────────────────── */
minimizar() {
// Capturar nombre e identificadores del chat activo
const chatName = document.getElementById('chat-name-text')?.textContent?.trim()
|| document.getElementById('chat-name')?.textContent?.trim()
|| 'Chat';
const chatPhone = document.getElementById('chat-phone')?.textContent?.trim() || '';
const chatUserId = (typeof chatApp !== 'undefined') ? (chatApp.currentUserId || 0) : 0;
// Construir objeto de sesión
const sesion = {
id: ++_sesionIdCounter,
chatUserId,
chatName,
chatPhone,
convId: _convId,
pacienteId: _pacienteId,
pacNombre: _pacNombre,
histDirs: [..._histDirs],
msgId: _msgId,
localFiles: [..._localFiles],
newOrderFiles: [..._newOrderFiles],
newOrderDataUrls: [..._newOrderDataUrls],
cardVisible: !document.getElementById('labdom-pac-card')?.classList.contains('d-none'),
pacInfo: {
nombre: document.getElementById('labdom-pac-nombre')?.value || '',
info: [document.getElementById('labdom-pac-tipodoc')?.value, document.getElementById('labdom-pac-cedula')?.value].filter(Boolean).join(' ') || '',
tel: document.getElementById('labdom-pac-tel')?.textContent || '',
email: document.getElementById('labdom-pac-email')?.textContent || '',
eps: document.getElementById('labdom-pac-eps')?.textContent || '',
},
fields: {},
};
// Capturar todos los campos del formulario
['labdom-fecha','labdom-hora','labdom-tipo','labdom-direccion','labdom-barrio',
'labdom-ciudad','labdom-indicaciones','labdom-examenes','labdom-notas',
'labdom-seguro-nombre','labdom-autorizacion','labdom-copago',
'labdom-valor-domicilio','labdom-valor-copago','labdom-pac-fnac',
'labdom-pac-correo','labdom-pac-busq','labdom-pac-nombre','labdom-pac-cedula',
'labdom-pac-tipodoc'].forEach(id => {
const el = document.getElementById(id);
if (el) sesion.fields[id] = el.value;
});
const tcr = document.querySelector('input[name="labdom-tipo-cliente"]:checked');
sesion.fields['labdom-tipo-cliente'] = tcr ? tcr.value : 'particular';
_sesiones.push(sesion);
// Cerrar el modal completamente
if (_bsModal) _bsModal.hide();
// Renderizar píldoras
labDomicilio._renderPills();
},
/* Renderiza la barra de píldoras de sesiones minimizadas */
_renderPills() {
let bar = document.getElementById('labdom-pills-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'labdom-pills-bar';
document.body.appendChild(bar);
}
bar.innerHTML = _sesiones.map((s, i) => {
const num = i + 1;
return `
<div class="labdom-pill" onclick="labDomicilio._restaurarSesion(${s.id})"
title="Restaurar — ${escLab(s.chatName)}${s.pacNombre ? ' · ' + escLab(s.pacNombre) : ''}">
<span class="labdom-pill-num">${num}</span>
<span class="labdom-pill-body">
<span class="labdom-pill-chat"><i class="fas fa-comments me-1" style="font-size:.65rem;opacity:.75"></i>${escLab(s.chatName)}</span>
${s.pacNombre ? `<span class="labdom-pill-pac"><i class="fas fa-user me-1" style="font-size:.6rem;opacity:.7"></i>${escLab(s.pacNombre)}</span>` : `<span class="labdom-pill-pac" style="opacity:.65">Sin paciente aún</span>`}
</span>
<button type="button" class="labdom-pill-close"
onclick="event.stopPropagation();labDomicilio._descartarSesion(${s.id})"
title="Descartar esta sesión">✕</button>
</div>`;
}).join('');
},
/* Restaura una sesión minimizada (y navega al chat si hace falta) */
async _restaurarSesion(sesId) {
const sesion = _sesiones.find(s => s.id === sesId);
if (!sesion) return;
// Quitar de la lista ANTES de abrir el modal
_sesiones = _sesiones.filter(s => s.id !== sesId);
labDomicilio._renderPills();
// Navegar al chat correspondiente si no es el activo
const chatUserId = (typeof chatApp !== 'undefined') ? (chatApp.currentUserId || 0) : 0;
if (sesion.chatUserId && sesion.chatUserId != chatUserId && typeof chatApp !== 'undefined') {
const conv = chatApp.conversations?.find(c => c.user_id == sesion.chatUserId);
if (conv) {
await chatApp.openConversation(conv.user_id, conv.name || sesion.chatName, conv.phone_number || sesion.chatPhone);
await new Promise(r => setTimeout(r, 280));
}
}
// Mostrar formulario y limpiar DOM
mostrarFormulario();
labDomicilio._resetFull(); // limpia el DOM
// Restaurar estado interno DESPUÉS del reset (para que no se sobreescriba)
_convId = sesion.convId;
_pacienteId = sesion.pacienteId;
_pacNombre = sesion.pacNombre;
_histDirs = sesion.histDirs || [];
_msgId = sesion.msgId;
_localFiles = sesion.localFiles || (sesion.localFile ? [sesion.localFile] : []);
_newOrderFiles = sesion.newOrderFiles || (sesion.newOrderFile ? [sesion.newOrderFile] : []);
_newOrderDataUrls = sesion.newOrderDataUrls || [];
// Restaurar campos del formulario
const f = sesion.fields || {};
['labdom-fecha','labdom-hora','labdom-tipo','labdom-direccion','labdom-barrio',
'labdom-ciudad','labdom-indicaciones','labdom-examenes','labdom-notas',
'labdom-seguro-nombre','labdom-autorizacion','labdom-copago',
'labdom-valor-domicilio','labdom-valor-copago','labdom-pac-fnac',
'labdom-pac-correo','labdom-pac-busq','labdom-pac-nombre','labdom-pac-cedula',
'labdom-pac-tipodoc'].forEach(id => {
const el = document.getElementById(id);
if (el && f[id] !== undefined) el.value = f[id];
});
const tcVal = f['labdom-tipo-cliente'] || 'particular';
const tcEl = document.querySelector(`input[name="labdom-tipo-cliente"][value="${tcVal}"]`);
if (tcEl) { tcEl.checked = true; labDomicilio._toggleSeguro(); }
// Restaurar tarjeta de paciente si había uno seleccionado
if (sesion.cardVisible && sesion.pacienteId) {
const $ = id => document.getElementById(id);
$('labdom-pac-buscar')?.classList.add('d-none');
$('labdom-pac-card')?.classList.remove('d-none');
$('labdom-btn-cambiar-pac')?.classList.remove('d-none');
if ($('labdom-pac-nombre')) $('labdom-pac-nombre').value = sesion.pacInfo.nombre || f['labdom-pac-nombre'] || '';
if ($('labdom-header-pac')) $('labdom-header-pac').textContent = sesion.pacNombre;
if ($('labdom-pac-tel')) $('labdom-pac-tel').textContent = sesion.pacInfo.tel;
if ($('labdom-pac-email')) $('labdom-pac-email').textContent = sesion.pacInfo.email;
if (sesion.pacInfo.eps && $('labdom-pac-eps')) {
$('labdom-pac-eps').textContent = sesion.pacInfo.eps;
if ($('labdom-pac-eps-row')) $('labdom-pac-eps-row').style.display = '';
}
if (sesion.pacInfo.tel || sesion.pacInfo.email) {
$('labdom-pac-contacto')?.classList.remove('d-none');
}
if (sesion.histDirs?.length) {
$('labdom-hist-cont')?.classList.remove('d-none');
if ($('labdom-hist-chips')) {
$('labdom-hist-chips').innerHTML = sesion.histDirs.map((d, i) =>
`<button type="button" class="addr-chip" onclick="labDomicilio.usarDireccion(${i})">${escLab(d.direccion)}${d.barrio ? ', '+escLab(d.barrio):''}</button>`
).join('');
}
}
}
labDomicilio._calcTotal();
labDomicilio._updateOrdenPreview();
// Abrir el modal
if (!_bsModal) _bsModal = new bootstrap.Modal('#modalAgendarDomicilio', { backdrop: true, keyboard: true });
_bsModal.show();
},
/* Descartar sesión con confirmación */
_descartarSesion(sesId) {
const s = _sesiones.find(x => x.id === sesId);
const nam = s ? `"${s.chatName}"` : 'este agendamiento';
if (!confirm(`¿Descartar el agendamiento en curso de ${nam}? Se perderán los datos.`)) return;
_sesiones = _sesiones.filter(x => x.id !== sesId);
labDomicilio._renderPills();
},
};
})();
// Enter en el buscador de paciente
document.addEventListener('keydown', e => {
if (e.target.id === 'labdom-pac-busq' && e.key === 'Enter') labDomicilio.buscarPaciente();
});
// Cerrar dropdown de paciente al hacer clic afuera
document.addEventListener('click', e => {
const list = document.getElementById('labdom-pac-list');
if (list && !list.contains(e.target) && e.target.id !== 'labdom-pac-busq') {
list.style.display = 'none';
}
});
const escLab = s => String(s||'').replace(/[<>&"'`]/g, c =>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;','`':'&#96;'}[c]));
const escAtr = s => String(s||'').replace(/"/g, '&quot;');
/* ─── Detección de direcciones colombianas en mensajes ─── */
window._detectAddr = function(text) {
if (!text) return null;
const re = /(calle|cl\.?|carrera|cra\.?|kr\.?|avenida|av\.?|diagonal|dg\.?|transversal|tv\.?|manzana|mz\.?)\s*[\d\w#\-\.]+[\s,][\s\d\w#\-\.°barrioaptolocalnorte sur este oeste]{4,60}/gi;
const m = String(text).match(re);
return (m && m.length) ? m[0].trim() : null;
};
window._labAddrBtn = function(msg, convId) {
if (msg.direction === 'outgoing') return '';
const addr = window._detectAddr(msg.body || msg.message || '');
if (!addr) return '';
const safeAddr = addr.replace(/'/g, "\\'").replace(/"/g, '&quot;');
return `<button class="btn btn-sm btn-link text-success" title="Agendar domicilio en esta dirección"
onclick="labDomicilio.abrirConTexto(${convId}, '${safeAddr}')"
><i class="fas fa-map-marker-alt"></i> Agendar</button>`;
};
/* ─── Modal "Ver citas del paciente" ─── */
const citasModal = (() => {
let _pacId = 0;
function abrir() {
/* Obtener paciente del contexto actual de la conversación */
const convId = (typeof chatApp !== 'undefined' && chatApp.currentConversationId)
? chatApp.currentConversationId : 0;
const el = document.getElementById('modalCitasPaciente');
if (!el) return;
const bsM = bootstrap.Modal.getOrCreateInstance(el);
bsM.show();
/* Usar el paciente activo en labDomicilio si existe */
_pacId = (typeof labDomicilio !== 'undefined' && labDomicilio.getPacienteId)
? labDomicilio.getPacienteId() : 0;
if (_pacId) {
const nom = labDomicilio.getPacienteNombre ? labDomicilio.getPacienteNombre() : '';
const h = document.getElementById('citas-pac-nombre');
if (h && nom) h.textContent = nom;
cargar(_pacId);
} else {
/* Si no hay paciente seleccionado, obtenerlo desde la conversación o usuario activo */
const userId = typeof chatApp !== 'undefined' ? (chatApp.currentUserId || 0) : 0;
const qparam = convId ? `conversation_id=${convId}` : (userId ? `user_id=${userId}` : '');
if (!qparam) { mostrarVacio('Abre una conversación primero.'); return; }
fetch(`api/lab/crear_desde_whatsapp.php?solo_paciente=1&${qparam}`)
.then(r => r.json())
.then(d => {
if (d.success && d.paciente && d.paciente.id) {
_pacId = d.paciente.id;
document.getElementById('citas-pac-nombre').textContent = d.paciente.nombre_completo || '';
cargar(_pacId);
} else {
mostrarVacio('No se encontró paciente asociado a esta conversación.');
}
})
.catch(() => mostrarVacio('Error al obtener datos del paciente.'));
}
}
function cargar(pacId) {
const tbody = document.getElementById('citas-tbody');
const header = document.getElementById('citas-pac-nombre');
if (!tbody) return;
tbody.innerHTML = `<tr><td colspan="5" class="text-center py-3"><div class="spinner-border spinner-border-sm text-primary" role="status"></div> Cargando…</td></tr>`;
fetch(`api/lab/get_domicilios.php?paciente_id=${pacId}&limit=20`)
.then(r => r.json())
.then(data => {
const rows = (data.data || data.domicilios || data || []);
if (!rows.length) { mostrarVacio('Este paciente no tiene citas registradas.'); return; }
if (rows[0] && rows[0].paciente_nombre && header) header.textContent = rows[0].paciente_nombre;
tbody.innerHTML = rows.map(c => {
const estado = c.estado || 'pendiente';
const colorMap = {pendiente:'warning',confirmado:'info',en_camino:'primary',completado:'success',cancelado:'danger'};
const badge = `<span class="badge bg-${colorMap[estado]||'secondary'}">${estado}</span>`;
const tipo = c.tipo_cliente ? `<span class="badge bg-light text-dark border">${c.tipo_cliente}</span>` : '';
const dir = escLab(c.direccion || '');
const fecha = c.fecha_programada ? c.fecha_programada.substring(0,16).replace('T',' ') : (c.fecha || '—');
return `<tr>
<td class="text-nowrap small">${escLab(fecha)}</td>
<td class="small">${dir}</td>
<td>${badge}</td>
<td>${tipo}</td>
<td class="text-nowrap">
<button class="btn btn-xs btn-outline-success py-0 px-2 small" title="Agendar con esta dirección"
onclick="labDomicilio._usarDirHistorial('${dir.replace(/'/g,"\\'")}')">
<i class="fas fa-location-arrow me-1"></i>Usar
</button>
</td>
</tr>`;
}).join('');
})
.catch(() => mostrarVacio('Error al cargar las citas.'));
}
function recargar() { if (_pacId) cargar(_pacId); }
function mostrarVacio(msg) {
const tbody = document.getElementById('citas-tbody');
if (tbody) tbody.innerHTML = `<tr><td colspan="5" class="text-center text-muted py-3">${escLab(msg)}</td></tr>`;
}
return { abrir, recargar };
})();
</script>
<!-- Modal: Citas del paciente -->
<div class="modal fade" id="modalCitasPaciente" tabindex="-1" aria-labelledby="modalCitasPacienteTitulo" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-warning bg-opacity-10">
<h5 class="modal-title" id="modalCitasPacienteTitulo">
<i class="fas fa-calendar-alt me-2 text-warning"></i>
Citas — <span id="citas-pac-nombre" class="text-primary">paciente</span>
</h5>
<div class="ms-auto d-flex gap-2 me-2">
<button class="btn btn-sm btn-outline-secondary" onclick="citasModal.recargar()" title="Recargar">
<i class="fas fa-sync-alt"></i>
</button>
<button class="btn btn-sm btn-outline-success" onclick="labDomicilio.abrirRapido()" title="Nueva cita">
<i class="fas fa-plus"></i> Nueva
</button>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
</div>
<div class="modal-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Fecha / Hora</th>
<th>Dirección</th>
<th>Estado</th>
<th>Tipo</th>
<th></th>
</tr>
</thead>
<tbody id="citas-tbody">
<tr><td colspan="5" class="text-center text-muted py-4"></td></tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
<!-- Barra de sesiones minimizadas (agendamientos en paralelo) -->
<div id="labdom-pills-bar"></div>
<script>
// ── Disponibilidad del asesor (menú secundario no_advisor) ────────────────
(function() {
var btn = document.getElementById('advisor-mode-btn');
var label = document.getElementById('advisor-mode-label');
function applyState(available) {
if (available) {
btn.textContent = '🟢 Disponible';
btn.style.background = '#25d366';
btn.style.color = '#fff';
if (label) label.textContent = 'El bot usa el menú principal';
} else {
btn.textContent = '🔴 Ausente';
btn.style.background = '#dc3545';
btn.style.color = '#fff';
if (label) label.textContent = 'El bot usa el menú de asesor ausente';
}
}
// Cargar estado inicial
fetch('api/set_advisor_mode.php', { credentials: 'same-origin' })
.then(function(r){ return r.json(); })
.then(function(d){ if (d && d.success !== undefined) applyState(d.available); })
.catch(function(){});
window.toggleAdvisorMode = function() {
var current = btn.textContent.indexOf('Disponible') !== -1;
var next = !current;
fetch('api/set_advisor_mode.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ available: next })
})
.then(function(r){ return r.json(); })
.then(function(d){
if (d && d.success) {
applyState(d.available);
var msg = d.available
? '✅ Modo asesor disponible activado. El bot usará el menú principal.'
: '⚠️ Asesor marcado como ausente. El bot usará el menú secundario.';
if (window.showToast) showToast(msg);
else alert(msg);
}
})
.catch(function(){ alert('Error al cambiar el modo de asesor.'); });
};
})();
</script>
</body>
</html>