fix: restaurar todas las optimizaciones móvil/tablet perdidas por force push
- chat.php: recuperado y re-aplicado panel-switch móvil, viewport iOS, safe-area, sticky topbar, link ← Menú, botón volver - lugar.php: panel-switch tablet (≤860px), botón ← Cola, JS mobile - recepcion.php: panel-switch tablet (≤900px), botón ← Cola, JS mobile - sidebar.php: soporte atributo target en links de módulo Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
be4cb88420
commit
fd95e7b38a
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/chat.php
|
||||
* Inbox del número WhatsApp del Turnero.
|
||||
* Permite ver y responder mensajes de pacientes que escriben al número turnero.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
$turneroPhoneId = getConfigFromDB('whatsapp_phone_number_id_turnero', '');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>Chat Turnero — WhatsApp</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; }
|
||||
|
||||
.chat-shell { display: flex; height: 100%; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 340px; min-width: 280px; max-width: 380px;
|
||||
background: #fff; border-right: 1px solid #e9ecef;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 16px; background: #25d366; color: #fff;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.sidebar-header h2 { font-size: 1rem; flex: 1; }
|
||||
.sidebar-header .badge-config {
|
||||
font-size: .7rem; background: rgba(0,0,0,.2); padding: 3px 8px; border-radius: 12px;
|
||||
}
|
||||
.search-box { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
|
||||
.search-box input {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
|
||||
border-radius: 20px; font-size: .85rem; outline: none;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.search-box input:focus { border-color: #25d366; background: #fff; }
|
||||
|
||||
.contact-list { flex: 1; overflow-y: auto; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #f7f7f7;
|
||||
transition: background .15s;
|
||||
}
|
||||
.contact-item:hover { background: #f5f5f5; }
|
||||
.contact-item.active { background: #e7fce8; }
|
||||
.contact-avatar {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
background: #25d366; color: #fff; display: flex; align-items: center;
|
||||
justify-content: center; font-weight: 700; font-size: 1.1rem; flex-shrink: 0;
|
||||
}
|
||||
.contact-info { flex: 1; min-width: 0; }
|
||||
.contact-name { font-weight: 600; font-size: .9rem; color: #111; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-last { font-size: .78rem; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
|
||||
.contact-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.contact-time { font-size: .7rem; color: #aaa; }
|
||||
.badge-unread {
|
||||
background: #25d366; color: #fff; font-size: .65rem; font-weight: 700;
|
||||
padding: 2px 6px; border-radius: 10px; min-width: 18px; text-align: center;
|
||||
}
|
||||
.contact-empty { text-align: center; padding: 40px 16px; color: #aaa; font-size: .85rem; }
|
||||
|
||||
/* ── Chat window ── */
|
||||
.chat-window {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
background: #efeae2;
|
||||
}
|
||||
.chat-empty-state {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; color: #aaa; gap: 12px;
|
||||
}
|
||||
.chat-empty-state i { font-size: 3rem; color: #25d366; }
|
||||
.chat-empty-state p { font-size: .95rem; }
|
||||
|
||||
.chat-topbar {
|
||||
background: #fff; padding: 12px 16px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
border-bottom: 1px solid #e9ecef; min-height: 60px;
|
||||
position: sticky; top: 0; z-index: 10; flex-shrink: 0;
|
||||
}
|
||||
.chat-topbar-avatar {
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: #25d366; color: #fff; display: flex;
|
||||
align-items: center; justify-content: center; font-weight: 700;
|
||||
}
|
||||
.chat-topbar-info h3 { font-size: .95rem; font-weight: 600; }
|
||||
.chat-topbar-info span { font-size: .78rem; color: #888; }
|
||||
|
||||
.messages-area {
|
||||
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
min-height: 0; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 65%; padding: 8px 12px; border-radius: 8px;
|
||||
font-size: .88rem; line-height: 1.4; position: relative;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg-bubble.incoming {
|
||||
background: #fff; align-self: flex-start;
|
||||
border-bottom-left-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||
}
|
||||
.msg-bubble.outgoing {
|
||||
background: #d9fdd3; align-self: flex-end;
|
||||
border-bottom-right-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||
}
|
||||
.msg-time {
|
||||
font-size: .68rem; color: #aaa; text-align: right; margin-top: 3px;
|
||||
}
|
||||
.msg-type-badge {
|
||||
font-size: .7rem; color: #888; font-style: italic;
|
||||
}
|
||||
.msg-media-thumb {
|
||||
max-width: 220px; max-height: 180px; border-radius: 6px;
|
||||
display: block; margin-bottom: 4px; cursor: pointer;
|
||||
}
|
||||
.msg-media-link {
|
||||
display: flex; align-items: center; gap: 6px; font-size: .82rem; color: #1a73e8;
|
||||
text-decoration: none; padding: 4px 0;
|
||||
}
|
||||
.msg-media-link:hover { text-decoration: underline; }
|
||||
|
||||
.load-more-btn {
|
||||
align-self: center; padding: 6px 18px; font-size: .8rem;
|
||||
background: #fff; border: 1px solid #ddd; border-radius: 16px;
|
||||
cursor: pointer; color: #555;
|
||||
}
|
||||
.load-more-btn:hover { background: #f5f5f5; }
|
||||
|
||||
/* ── Input area ── */
|
||||
.chat-input-area {
|
||||
background: #f0f2f5; padding: 10px 16px;
|
||||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||
display: flex; align-items: flex-end; gap: 10px;
|
||||
border-top: 1px solid #e0e0e0; flex-shrink: 0;
|
||||
}
|
||||
.chat-input-area textarea {
|
||||
flex: 1; padding: 10px 14px; border: none; border-radius: 22px;
|
||||
background: #fff; font-size: .9rem; resize: none; outline: none;
|
||||
max-height: 120px; line-height: 1.4;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.btn-send {
|
||||
width: 42px; height: 42px; border-radius: 50%; border: none;
|
||||
background: #25d366; color: #fff; font-size: 1.1rem;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; transition: background .2s;
|
||||
}
|
||||
.btn-send:hover { background: #1ebe5c; }
|
||||
.btn-send:disabled { background: #ccc; cursor: default; }
|
||||
|
||||
/* ── Alertas ── */
|
||||
.alert-no-config {
|
||||
background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px;
|
||||
padding: 12px 16px; margin: 16px; font-size: .85rem; color: #856404;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.typing-indicator { font-size: .75rem; color: #888; padding: 2px 8px; }
|
||||
|
||||
.back-btn { display: none; background: none; border: none; color: #fff; font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.chat-shell { position: relative; overflow: hidden; }
|
||||
.sidebar { position: absolute; inset: 0; z-index: 10; transition: transform .2s ease; }
|
||||
.sidebar.mobile-hidden { transform: translateX(-100%); pointer-events: none; }
|
||||
.chat-window { position: absolute; inset: 0; z-index: 5; transform: translateX(100%); height: 100%; transition: transform .2s ease; }
|
||||
.chat-window.mobile-active { transform: translateX(0); }
|
||||
.back-btn { display: flex; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-shell">
|
||||
|
||||
<!-- ── Sidebar: lista de contactos ── -->
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=dashboard"
|
||||
style="color:rgba(255,255,255,.7);font-size:.85rem;text-decoration:none;display:flex;align-items:center;gap:5px;margin-bottom:8px">
|
||||
<i class="fas fa-arrow-left"></i> Menú
|
||||
</a>
|
||||
<i class="fab fa-whatsapp" style="font-size:1.4rem"></i>
|
||||
<h2>Chat Turnero</h2>
|
||||
<?php if ($turneroPhoneId): ?>
|
||||
<span class="badge-config" title="Número turnero configurado">
|
||||
<i class="fas fa-check-circle"></i> Activo
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge-config" style="background:rgba(220,53,69,.6)" title="Sin configurar">
|
||||
<i class="fas fa-exclamation-circle"></i> Sin config
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="Buscar contacto…" autocomplete="off">
|
||||
</div>
|
||||
<div class="contact-list" id="contactList">
|
||||
<div class="contact-empty"><i class="fas fa-spinner fa-spin"></i><br>Cargando…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Ventana de chat ── -->
|
||||
<div class="chat-window" id="chatWindow">
|
||||
|
||||
<?php if (!$turneroPhoneId): ?>
|
||||
<div class="alert-no-config">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
El número de WhatsApp del Turnero no está configurado.
|
||||
<a href="/erp.php?m=turnero&v=configuracion" style="margin-left:8px;color:#856404;font-weight:600;">Configurar ahora</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="chat-empty-state" id="emptyState">
|
||||
<i class="fab fa-whatsapp"></i>
|
||||
<p>Selecciona un contacto para ver la conversación</p>
|
||||
</div>
|
||||
|
||||
<div id="activeChat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
|
||||
<div class="chat-topbar" id="chatTopbar">
|
||||
<button class="back-btn" onclick="closeChatMobile()"><i class="fas fa-arrow-left"></i></button>
|
||||
<div class="chat-topbar-avatar" id="topbarAvatar">?</div>
|
||||
<div class="chat-topbar-info">
|
||||
<h3 id="topbarName">—</h3>
|
||||
<span id="topbarPhone">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-area" id="messagesArea">
|
||||
<button class="load-more-btn" id="loadMoreBtn" style="display:none" onclick="loadMoreMessages()">
|
||||
<i class="fas fa-chevron-up"></i> Ver mensajes anteriores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="typingIndicator" class="typing-indicator" style="display:none">Escribiendo…</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<textarea id="msgInput" rows="1" placeholder="Escribe un mensaje…"
|
||||
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||||
<button class="btn-send" id="btnSend" onclick="sendMessage()" <?= $turneroPhoneId ? '' : 'disabled' ?>>
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /chat-window -->
|
||||
</div><!-- /chat-shell -->
|
||||
|
||||
<script>
|
||||
const API_LIST = 'modules/turnero/api/chat_get_list.php';
|
||||
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
|
||||
const API_SEND = 'modules/turnero/api/chat_send_message.php';
|
||||
const API_READ = 'modules/turnero/api/chat_mark_read.php';
|
||||
const BASE = (function() {
|
||||
const s = window.location.pathname;
|
||||
return s.replace(/\/erp\.php.*$/, '/') || '/';
|
||||
})();
|
||||
|
||||
let state = {
|
||||
contacts: [],
|
||||
activeUserId: null,
|
||||
activeUser: null,
|
||||
messages: [],
|
||||
earliest: null,
|
||||
earliestId: null,
|
||||
hasMore: false,
|
||||
polling: null,
|
||||
lastPollTime: null,
|
||||
};
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContacts();
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(() => {
|
||||
loadContacts(document.getElementById('searchInput').value.trim());
|
||||
}, 300));
|
||||
setInterval(pollMessages, 4000);
|
||||
setInterval(() => loadContacts(document.getElementById('searchInput').value.trim(), true), 12000);
|
||||
});
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// ── Contactos ─────────────────────────────────────────────────────────────────
|
||||
async function loadContacts(search = '', silent = false) {
|
||||
const url = BASE + API_LIST + '?limit=100' + (search ? '&search=' + encodeURIComponent(search) : '');
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (!json.success) return;
|
||||
state.contacts = json.data;
|
||||
renderContacts(json.data);
|
||||
} catch(e) {
|
||||
if (!silent) console.error('loadContacts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContacts(contacts) {
|
||||
const el = document.getElementById('contactList');
|
||||
if (!contacts.length) {
|
||||
el.innerHTML = '<div class="contact-empty"><i class="fas fa-comment-slash"></i><br>Sin conversaciones aún</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = contacts.map(c => {
|
||||
const initials = (c.name || c.phone_number).charAt(0).toUpperCase();
|
||||
const preview = escHtml(c.last_message || '—');
|
||||
const time = c.last_time ? formatTime(c.last_time) : '';
|
||||
const unread = c.unread_count > 0 ? `<span class="badge-unread">${c.unread_count}</span>` : '';
|
||||
const active = state.activeUserId === c.user_id ? ' active' : '';
|
||||
return `<div class="contact-item${active}" onclick="openChat(${c.user_id})">
|
||||
<div class="contact-avatar">${initials}</div>
|
||||
<div class="contact-info">
|
||||
<div class="contact-name">${escHtml(c.name || c.phone_number)}</div>
|
||||
<div class="contact-last">${preview}</div>
|
||||
</div>
|
||||
<div class="contact-meta">
|
||||
<span class="contact-time">${time}</span>
|
||||
${unread}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Abrir chat ────────────────────────────────────────────────────────────────
|
||||
async function openChat(userId) {
|
||||
state.activeUserId = userId;
|
||||
state.messages = [];
|
||||
state.earliest = null;
|
||||
state.earliestId = null;
|
||||
state.hasMore = false;
|
||||
state.lastPollTime = null;
|
||||
|
||||
const contact = state.contacts.find(c => c.user_id === userId);
|
||||
state.activeUser = contact;
|
||||
|
||||
document.getElementById('emptyState').style.display = 'none';
|
||||
const activeEl = document.getElementById('activeChat');
|
||||
activeEl.style.display = 'flex';
|
||||
activeEl.style.flexDirection = 'column';
|
||||
activeEl.style.overflow = 'hidden';
|
||||
activeEl.style.flex = '1';
|
||||
|
||||
const initials = contact ? (contact.name || contact.phone_number).charAt(0).toUpperCase() : '?';
|
||||
document.getElementById('topbarAvatar').textContent = initials;
|
||||
document.getElementById('topbarName').textContent = contact ? (contact.name || contact.phone_number) : '…';
|
||||
document.getElementById('topbarPhone').textContent = contact ? contact.phone_number : '';
|
||||
|
||||
document.querySelectorAll('.contact-item').forEach(el => el.classList.remove('active'));
|
||||
const items = document.querySelectorAll('.contact-item');
|
||||
items.forEach(el => { if (el.onclick.toString().includes(`openChat(${userId})`)) el.classList.add('active'); });
|
||||
|
||||
const area = document.getElementById('messagesArea');
|
||||
area.innerHTML = '<div style="text-align:center;padding:20px;color:#aaa"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||
|
||||
await fetchMessages();
|
||||
markRead(userId);
|
||||
loadContacts(document.getElementById('searchInput').value.trim(), true);
|
||||
openChatMobile();
|
||||
}
|
||||
|
||||
function isMobile() { return window.innerWidth <= 680; }
|
||||
function openChatMobile() {
|
||||
if (!isMobile()) return;
|
||||
document.getElementById('sidebar').classList.add('mobile-hidden');
|
||||
document.getElementById('chatWindow').classList.add('mobile-active');
|
||||
}
|
||||
function closeChatMobile() {
|
||||
if (!isMobile()) return;
|
||||
document.getElementById('sidebar').classList.remove('mobile-hidden');
|
||||
document.getElementById('chatWindow').classList.remove('mobile-active');
|
||||
}
|
||||
|
||||
// ── Mensajes ──────────────────────────────────────────────────────────────────
|
||||
async function fetchMessages() {
|
||||
if (!state.activeUserId) return;
|
||||
const url = BASE + API_MESSAGES + '?user_id=' + state.activeUserId + '&limit=50';
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (!json.success) return;
|
||||
state.messages = json.data;
|
||||
state.hasMore = json.has_more;
|
||||
state.earliest = json.earliest;
|
||||
state.earliestId = json.earliest_id;
|
||||
state.lastPollTime = json.data.length ? json.data[json.data.length - 1].created_at : null;
|
||||
renderMessages(json.data, false);
|
||||
} catch(e) { console.error('fetchMessages:', e); }
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (!state.activeUserId || !state.earliest) return;
|
||||
const url = BASE + API_MESSAGES
|
||||
+ '?user_id=' + state.activeUserId
|
||||
+ '&limit=50'
|
||||
+ '&before=' + encodeURIComponent(state.earliest)
|
||||
+ (state.earliestId ? '&before_id=' + state.earliestId : '');
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (!json.success || !json.data.length) {
|
||||
document.getElementById('loadMoreBtn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
state.messages = [...json.data, ...state.messages];
|
||||
state.hasMore = json.has_more;
|
||||
state.earliest = json.earliest;
|
||||
state.earliestId = json.earliest_id;
|
||||
prependMessages(json.data);
|
||||
} catch(e) { console.error('loadMoreMessages:', e); }
|
||||
}
|
||||
|
||||
async function pollMessages() {
|
||||
if (!state.activeUserId || !state.lastPollTime) return;
|
||||
const url = BASE + API_MESSAGES
|
||||
+ '?user_id=' + state.activeUserId
|
||||
+ '&limit=50'
|
||||
+ '&since=' + encodeURIComponent(state.lastPollTime);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const json = await res.json();
|
||||
if (!json.success || !json.data.length) return;
|
||||
json.data.forEach(m => {
|
||||
if (!state.messages.find(x => x.id === m.id)) {
|
||||
state.messages.push(m);
|
||||
appendMessage(m);
|
||||
}
|
||||
});
|
||||
state.lastPollTime = json.data[json.data.length - 1].created_at;
|
||||
markRead(state.activeUserId);
|
||||
loadContacts(document.getElementById('searchInput').value.trim(), true);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Render mensajes ───────────────────────────────────────────────────────────
|
||||
function renderMessages(msgs, prepend) {
|
||||
const area = document.getElementById('messagesArea');
|
||||
area.innerHTML = '';
|
||||
|
||||
const loadBtn = document.createElement('button');
|
||||
loadBtn.className = 'load-more-btn';
|
||||
loadBtn.id = 'loadMoreBtn';
|
||||
loadBtn.style.display = state.hasMore ? 'block' : 'none';
|
||||
loadBtn.onclick = loadMoreMessages;
|
||||
loadBtn.innerHTML = '<i class="fas fa-chevron-up"></i> Ver mensajes anteriores';
|
||||
area.appendChild(loadBtn);
|
||||
|
||||
msgs.forEach(m => area.appendChild(buildBubble(m)));
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
|
||||
function prependMessages(msgs) {
|
||||
const area = document.getElementById('messagesArea');
|
||||
const oldTop = area.scrollHeight - area.scrollTop;
|
||||
const loadBtn = document.getElementById('loadMoreBtn');
|
||||
msgs.forEach(m => area.insertBefore(buildBubble(m), loadBtn.nextSibling));
|
||||
if (!state.hasMore) loadBtn.style.display = 'none';
|
||||
area.scrollTop = area.scrollHeight - oldTop;
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
const area = document.getElementById('messagesArea');
|
||||
const bubble = buildBubble(msg);
|
||||
area.appendChild(bubble);
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
|
||||
function buildBubble(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg-bubble ' + (msg.direction === 'outgoing' ? 'outgoing' : 'incoming');
|
||||
div.dataset.msgId = msg.id;
|
||||
|
||||
let content = '';
|
||||
|
||||
if (msg.message_type === 'image' && (msg.local_file || msg.media_url_external)) {
|
||||
const src = msg.local_file ? ('../' + msg.local_file) : msg.media_url_external;
|
||||
content += `<img class="msg-media-thumb" src="${escHtml(src)}" onclick="window.open('${escHtml(src)}','_blank')" alt="imagen">`;
|
||||
if (msg.content) content += `<div>${escHtml(msg.content)}</div>`;
|
||||
} else if (['audio','video','document'].includes(msg.message_type) && (msg.local_file || msg.media_url_external)) {
|
||||
const src = msg.local_file ? ('../' + msg.local_file) : msg.media_url_external;
|
||||
const icon = msg.message_type === 'audio' ? 'fa-file-audio' : msg.message_type === 'video' ? 'fa-file-video' : 'fa-file-alt';
|
||||
content += `<a class="msg-media-link" href="${escHtml(src)}" target="_blank">
|
||||
<i class="fas ${icon}"></i> ${escHtml(msg.filename || msg.message_type)}
|
||||
</a>`;
|
||||
} else {
|
||||
content = nl2br(escHtml(msg.content || ''));
|
||||
}
|
||||
|
||||
const time = msg.created_at ? formatTimeFull(msg.created_at) : '';
|
||||
div.innerHTML = content + `<div class="msg-time">${time}</div>`;
|
||||
return div;
|
||||
}
|
||||
|
||||
// ── Enviar ────────────────────────────────────────────────────────────────────
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('msgInput');
|
||||
const message = input.value.trim();
|
||||
if (!message || !state.activeUserId) return;
|
||||
|
||||
const btn = document.getElementById('btnSend');
|
||||
btn.disabled = true;
|
||||
input.value = '';
|
||||
autoResize(input);
|
||||
|
||||
try {
|
||||
const res = await fetch(BASE + API_SEND, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: state.activeUserId, message })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
alert('Error al enviar: ' + (json.error || 'desconocido'));
|
||||
input.value = message;
|
||||
} else {
|
||||
await pollMessages();
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Error de red al enviar el mensaje');
|
||||
input.value = message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(userId) {
|
||||
try {
|
||||
await fetch(BASE + API_READ, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
}
|
||||
function autoResize(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
function escHtml(str) {
|
||||
return String(str ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function nl2br(str) { return str.replace(/\n/g, '<br>'); }
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts.replace(' ', 'T'));
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString('es-CO', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
function formatTimeFull(ts) {
|
||||
const d = new Date(ts.replace(' ', 'T'));
|
||||
return d.toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -54,8 +54,12 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.lugar-layout { grid-template-columns: 1fr; }
|
||||
.lugar-cola { max-height: 240px; }
|
||||
.lugar-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
|
||||
.lugar-cola { transition: transform .2s ease; }
|
||||
.lugar-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
|
||||
.lugar-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
|
||||
.lugar-ficha.mobile-visible { transform: translateX(0); }
|
||||
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── Columna cola ── */
|
||||
@@ -256,6 +260,11 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
<!-- ══ Ficha del turno ════════════════════════════════ -->
|
||||
<div class="lugar-ficha" id="lugar-ficha">
|
||||
|
||||
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
|
||||
style="display:none" onclick="volverACola()">
|
||||
<i class="fas fa-arrow-left"></i> Cola
|
||||
</button>
|
||||
|
||||
<div class="ficha-placeholder" id="ficha-placeholder">
|
||||
<i class="fas fa-stethoscope fa-3x mb-3" style="color:#cbd5e1"></i>
|
||||
<p class="fw-semibold mb-1">Sin turno activo</p>
|
||||
@@ -508,6 +517,7 @@ async function seleccionarSinLlamar(turnoId) {
|
||||
await cargarFichaSolicitud(t.id);
|
||||
clearInterval(pollingConsentId);
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
|
||||
async function rellamarDesdeCard(turnoId) {
|
||||
@@ -580,6 +590,7 @@ async function abrirFicha(turno) {
|
||||
// Iniciar polling de consentimientos
|
||||
clearInterval(pollingConsentId);
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
|
||||
// ── Cargar datos de la solicitud del turno ────────────────────
|
||||
@@ -823,6 +834,19 @@ function resetFicha() {
|
||||
document.getElementById('btn-iniciar').disabled = false;
|
||||
document.getElementById('btn-finalizar').classList.add('d-none');
|
||||
document.getElementById('btn-regresar').classList.add('d-none');
|
||||
|
||||
volverACola();
|
||||
}
|
||||
|
||||
function esMobile() { return window.innerWidth <= 860; }
|
||||
function mostrarFichaMobile() {
|
||||
if (!esMobile()) return;
|
||||
document.getElementById('lugar-ficha').classList.add('mobile-visible');
|
||||
document.querySelector('.lugar-cola').classList.add('mobile-oculta');
|
||||
}
|
||||
function volverACola() {
|
||||
document.getElementById('lugar-ficha').classList.remove('mobile-visible');
|
||||
document.querySelector('.lugar-cola').classList.remove('mobile-oculta');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
@@ -88,8 +88,12 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.rec-layout { grid-template-columns: 1fr; }
|
||||
.rec-cola { max-height: 260px; }
|
||||
.rec-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
|
||||
.rec-cola { transition: transform .2s ease; }
|
||||
.rec-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
|
||||
.rec-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
|
||||
.rec-ficha.mobile-visible { transform: translateX(0); }
|
||||
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
|
||||
}
|
||||
|
||||
/* ── Columna cola ── */
|
||||
@@ -347,6 +351,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
<!-- ══ Columna derecha: ficha del turno ═══════════════ -->
|
||||
<div class="rec-ficha" id="rec-ficha">
|
||||
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
|
||||
style="display:none" onclick="volverACola()">
|
||||
<i class="fas fa-arrow-left"></i> Cola
|
||||
</button>
|
||||
|
||||
<div class="ficha-placeholder" id="ficha-placeholder">
|
||||
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
|
||||
<p class="mb-0 fw-semibold">Sin turno activo</p>
|
||||
@@ -1038,6 +1047,7 @@ function abrirFicha(turno) {
|
||||
|
||||
// Cargar consentimientos del desk actual para este turno
|
||||
if (DESK_ID) cargarConsentimientosDesk(turno.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
|
||||
// ── Cargar consentimientos del desk para el turno ─────────────
|
||||
@@ -1637,6 +1647,7 @@ async function marcarAusente() {
|
||||
document.getElementById('ficha-placeholder').classList.remove('d-none');
|
||||
document.getElementById('badge-turno-activo').classList.add('d-none');
|
||||
cargarCola();
|
||||
volverACola();
|
||||
}
|
||||
|
||||
// ── Soltar turno ──────────────────────────────────────────────
|
||||
@@ -1657,6 +1668,7 @@ async function soltarTurno() {
|
||||
document.getElementById('badge-turno-activo').classList.add('d-none');
|
||||
mostrarToast('Turno liberado', 'success');
|
||||
cargarCola();
|
||||
volverACola();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
@@ -1691,6 +1703,18 @@ function resetPlaceholder() {
|
||||
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
|
||||
<p class="mb-0 fw-semibold">Sin turno activo</p>
|
||||
<small class="text-muted">Haga clic en "Llamar siguiente" o en <i class="fas fa-bell"></i> de un turno de la cola</small>`;
|
||||
volverACola();
|
||||
}
|
||||
|
||||
function esMobile() { return window.innerWidth <= 900; }
|
||||
function mostrarFichaMobile() {
|
||||
if (!esMobile()) return;
|
||||
document.getElementById('rec-ficha').classList.add('mobile-visible');
|
||||
document.querySelector('.rec-cola').classList.add('mobile-oculta');
|
||||
}
|
||||
function volverACola() {
|
||||
document.getElementById('rec-ficha').classList.remove('mobile-visible');
|
||||
document.querySelector('.rec-cola').classList.remove('mobile-oculta');
|
||||
}
|
||||
|
||||
function mostrarError(msg) {
|
||||
|
||||
@@ -188,7 +188,8 @@ if (class_exists('ModuleRegistry', false)) {
|
||||
$active = $_isActive($lnk['route']);
|
||||
?>
|
||||
<li><a href="<?= htmlspecialchars($href, ENT_QUOTES, 'UTF-8') ?>"
|
||||
class="nav-link<?= $active ?>">
|
||||
class="nav-link<?= $active ?>"
|
||||
<?= !empty($lnk['target']) ? 'target="' . htmlspecialchars($lnk['target'], ENT_QUOTES, 'UTF-8') . '"' : '' ?>>
|
||||
<i class="<?= htmlspecialchars($lnk['icon'], ENT_QUOTES, 'UTF-8') ?>"></i>
|
||||
<?= htmlspecialchars($lnk['name'], ENT_QUOTES, 'UTF-8') ?></a></li>
|
||||
<?php endforeach;
|
||||
|
||||
Reference in New Issue
Block a user