feat(chat): botón Nuevo mensaje con búsqueda de paciente y picker de plantilla

- chat_buscar_paciente.php: GET ?q= busca en lab_pacientes por nombre,
  documento o teléfono (mín. 2 chars, máx. 20 resultados)
- chat_start_conversation.php: POST {phone, template_name, lang, params}
  — normaliza teléfono, busca/crea usuario en users, envía plantilla
  vía WhatsAppService('turnero'), devuelve {ok, user_id}
- chat.php: botón "+ Nuevo" en sidebar header; modal 3 pasos:
  1) buscar paciente (debounce 350ms) o ingresar número libre
  2) lista de plantillas habilitadas
  3) variables de la plantilla + Enviar
  Al enviar recarga el sidebar y abre la conversación automáticamente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-03 17:19:18 -05:00
co-authored by Claude Sonnet 4.6
parent 67efd470de
commit ab72750410
3 changed files with 400 additions and 1 deletions
@@ -0,0 +1,29 @@
<?php
/**
* GET ?q= — Busca pacientes en lab_pacientes por nombre o documento.
* Usado por el modal "Nuevo mensaje" del chat turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
$q = trim($_GET['q'] ?? '');
if (strlen($q) < 2) { echo json_encode(['ok' => true, 'data' => []]); exit; }
try {
$pdo = Database::getInstance()->getConnection();
$like = '%' . $q . '%';
$rows = $pdo->prepare(
"SELECT id, nombre_completo, telefono, numero_documento
FROM lab_pacientes
WHERE is_active = 1
AND (nombre_completo LIKE ? OR numero_documento LIKE ? OR telefono LIKE ?)
ORDER BY nombre_completo ASC
LIMIT 20"
);
$rows->execute([$like, $like, $like]);
echo json_encode(['ok' => true, 'data' => $rows->fetchAll(PDO::FETCH_ASSOC)]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
@@ -0,0 +1,81 @@
<?php
/**
* POST — Inicia una conversación nueva desde el chat turnero.
* Body: { phone, template_name, lang, params }
* Busca o crea el usuario en `users`, envía la plantilla y devuelve user_id.
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/WhatsAppService.php';
requireAuthentication();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); echo json_encode(['ok' => false, 'error' => 'Método no permitido']); exit;
}
try {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$phone = trim($input['phone'] ?? '');
$templateName = trim($input['template_name'] ?? '');
$lang = trim($input['lang'] ?? 'es_CO');
$params = $input['params'] ?? [];
if (!$phone || !$templateName) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'phone y template_name son requeridos']); exit;
}
// Normalizar teléfono: solo dígitos, agregar 57 si es colombiano de 10 dígitos
$phoneClean = preg_replace('/\D/', '', $phone);
if (strlen($phoneClean) === 10 && $phoneClean[0] === '3') {
$phoneClean = '57' . $phoneClean;
}
if (strlen($phoneClean) < 10) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Número de teléfono inválido']); exit;
}
$db = Database::getInstance();
$pdo = $db->getConnection();
// Buscar usuario existente (puede estar guardado con o sin prefijo 57)
$user = $pdo->prepare(
"SELECT id, name FROM users WHERE phone_number = ? OR phone_number = ? LIMIT 1"
);
$user->execute([$phoneClean, ltrim($phoneClean, '57')]);
$userRow = $user->fetch(PDO::FETCH_ASSOC);
if (!$userRow) {
// Buscar nombre en lab_pacientes por teléfono
$pac = $pdo->prepare(
"SELECT nombre_completo FROM lab_pacientes WHERE telefono LIKE ? AND is_active=1 LIMIT 1"
);
$pac->execute(['%' . substr($phoneClean, -10) . '%']);
$pacRow = $pac->fetch(PDO::FETCH_ASSOC);
$nombre = $pacRow['nombre_completo'] ?? $phoneClean;
// Crear usuario mínimo
$ins = $pdo->prepare(
"INSERT INTO users (name, phone_number, created_at) VALUES (?, ?, NOW())"
);
$ins->execute([$nombre, $phoneClean]);
$userId = (int)$pdo->lastInsertId();
} else {
$userId = (int)$userRow['id'];
}
// Enviar plantilla
$operatorId = $_SESSION['admin_id'] ?? $_SESSION['user_id'] ?? null;
$meta = ['canal' => 'turnero'];
if ($operatorId) $meta['operator_id'] = $operatorId;
$wa = new WhatsAppService('turnero');
$wa->sendTemplateMessage($phoneClean, $templateName, $lang, $params, [], null, $meta);
echo json_encode(['ok' => true, 'user_id' => $userId]);
} catch (Throwable $e) {
error_log('chat_start_conversation: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}
+290 -1
View File
@@ -37,6 +37,12 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
.sidebar-header .badge-config {
font-size: .7rem; background: rgba(0,0,0,.2); padding: 3px 8px; border-radius: 12px;
}
.btn-nuevo-chat {
background: rgba(255,255,255,.2); border: none; color: #fff; border-radius: 8px;
padding: 5px 10px; cursor: pointer; font-size: .82rem; font-weight: 700;
display: flex; align-items: center; gap: 4px; transition: background .15s; flex-shrink: 0;
}
.btn-nuevo-chat:hover { background: rgba(255,255,255,.35); }
.search-box { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; }
.search-box input {
width: 100%; padding: 8px 12px; border: 1px solid #e0e0e0;
@@ -271,6 +277,38 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
.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; }
/* ── Modal nuevo mensaje ── */
#nuevo-modal { display:none; position:fixed; inset:0; z-index:1100;
background:rgba(0,0,0,.45); align-items:center; justify-content:center; padding:16px; }
#nuevo-modal.show { display:flex; }
.nv-dialog { background:#fff; border-radius:14px; width:100%; max-width:420px;
box-shadow:0 8px 32px rgba(0,0,0,.22); overflow:hidden; display:flex; flex-direction:column; max-height:90vh; }
.nv-hdr { display:flex; align-items:center; gap:8px; padding:13px 16px;
border-bottom:1px solid #f1f5f9; font-size:.92rem; font-weight:700; color:#1e293b; flex-shrink:0; }
.nv-hdr > span { flex:1; }
.nv-body { padding:14px 16px; overflow-y:auto; flex:1; }
.nv-footer { display:flex; gap:8px; justify-content:flex-end; padding:12px 16px;
border-top:1px solid #f1f5f9; flex-shrink:0; }
.nv-search { width:100%; padding:8px 12px; border:1.5px solid #e2e8f0; border-radius:8px;
font-size:.87rem; outline:none; box-sizing:border-box; margin-bottom:10px; }
.nv-search:focus { border-color:#25d366; }
.nv-pac-item { display:flex; flex-direction:column; gap:1px; padding:9px 10px; cursor:pointer;
border-radius:8px; transition:background .12s; border:1px solid transparent; }
.nv-pac-item:hover { background:#f0fdf4; border-color:#bbf7d0; }
.nv-pac-name { font-size:.87rem; font-weight:600; color:#1e293b; }
.nv-pac-sub { font-size:.75rem; color:#64748b; }
.nv-divider { display:flex; align-items:center; gap:8px; margin:10px 0;
font-size:.75rem; color:#94a3b8; }
.nv-divider::before,.nv-divider::after { content:''; flex:1; height:1px; background:#e2e8f0; }
.nv-destinatario { display:flex; align-items:center; gap:8px; padding:8px 10px;
background:#f0fdf4; border:1px solid #bbf7d0; border-radius:8px; margin-bottom:10px; font-size:.85rem; }
.nv-destinatario strong { flex:1; color:#15803d; }
.nv-back-btn { background:none; border:none; cursor:pointer; color:#64748b;
font-size:.85rem; padding:2px 7px; border-radius:4px; }
.nv-back-btn:hover { background:#f1f5f9; }
.nv-close-btn { background:none; border:none; cursor:pointer; color:#94a3b8;
font-size:.95rem; padding:2px 7px; border-radius:4px; }
.nv-close-btn:hover { color:#475569; background:#f1f5f9; }
}
</style>
</head>
@@ -295,6 +333,9 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
<i class="fas fa-exclamation-circle"></i> Sin config
</span>
<?php endif; ?>
<button class="btn-nuevo-chat" onclick="nuevoMensaje.abrir()" title="Nuevo mensaje">
<i class="fas fa-plus"></i> Nuevo
</button>
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="Buscar contacto…" autocomplete="off">
@@ -382,6 +423,69 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
<?php endforeach; ?>
</div>
<!-- ── Modal nuevo mensaje (3 pasos) ── -->
<div id="nuevo-modal">
<!-- Paso 1: destinatario -->
<div class="nv-dialog" id="nv-step-dest">
<div class="nv-hdr">
<span><i class="fas fa-user-plus me-2" style="color:#25d366"></i>Nuevo mensaje</span>
<button class="nv-close-btn" onclick="nuevoMensaje.cerrar()">✕</button>
</div>
<div class="nv-body">
<input class="nv-search" id="nv-pac-search" placeholder="🔍 Buscar paciente por nombre o cédula…"
autocomplete="off" oninput="nuevoMensaje.buscarPaciente(this.value)">
<div id="nv-pac-results" style="display:flex;flex-direction:column;gap:4px;min-height:40px"></div>
<div class="nv-divider">o ingresa el número directamente</div>
<input class="nv-search" id="nv-phone-input" placeholder="📱 Número de WhatsApp (ej: 3001234567)"
autocomplete="off" inputmode="tel"
oninput="nuevoMensaje.onPhoneInput(this.value)">
<div id="nv-phone-hint" style="font-size:.76rem;color:#94a3b8;margin-top:-6px;margin-bottom:4px"></div>
</div>
<div class="nv-footer">
<button class="btn btn-outline-secondary btn-sm" onclick="nuevoMensaje.cerrar()">Cancelar</button>
<button class="btn btn-success btn-sm fw-semibold" id="nv-btn-siguiente"
onclick="nuevoMensaje.irAPlantilla()" disabled>
Siguiente <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div>
<!-- Paso 2: plantilla -->
<div class="nv-dialog" id="nv-step-tpl" style="display:none">
<div class="nv-hdr">
<button class="nv-back-btn" onclick="nuevoMensaje.volverADest()"><i class="fas fa-arrow-left"></i></button>
<span>Seleccionar plantilla</span>
<button class="nv-close-btn" onclick="nuevoMensaje.cerrar()">✕</button>
</div>
<div class="nv-body">
<div class="nv-destinatario">
<i class="fas fa-user-circle" style="color:#25d366;font-size:1.1rem"></i>
<strong id="nv-dest-label">—</strong>
</div>
<div id="nv-tpl-list" style="display:flex;flex-direction:column;gap:4px;overflow-y:auto;max-height:300px"></div>
</div>
</div>
<!-- Paso 3: variables -->
<div class="nv-dialog" id="nv-step-vars" style="display:none">
<div class="nv-hdr">
<button class="nv-back-btn" onclick="nuevoMensaje.volverAPlantilla()"><i class="fas fa-arrow-left"></i></button>
<span id="nv-vars-title">Completar variables</span>
<button class="nv-close-btn" onclick="nuevoMensaje.cerrar()">✕</button>
</div>
<div class="nv-body">
<div id="nv-preview-box" style="display:none;background:#f0fdf4;border-left:3px solid #25d366;
border-radius:0 6px 6px 0;padding:8px 12px;font-size:.82rem;color:#374151;
margin-bottom:10px;white-space:pre-wrap;word-break:break-word"></div>
<div id="nv-vars-body" style="display:flex;flex-direction:column;gap:.6rem"></div>
</div>
<div class="nv-footer">
<button class="btn btn-outline-secondary btn-sm" onclick="nuevoMensaje.volverAPlantilla()">Atrás</button>
<button class="btn btn-success btn-sm fw-semibold" onclick="nuevoMensaje.enviar()">
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
</div>
</div>
</div>
<!-- ── Modal plantilla (2 pasos) ── -->
<div id="tpl-modal">
<!-- Paso 1: lista -->
@@ -415,7 +519,9 @@ const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.php';
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
const API_REACT = 'modules/turnero/api/chat_react.php';
const API_PLANTILLAS = 'modules/turnero/api/chat_get_plantillas.php';
const API_PLANTILLAS = 'modules/turnero/api/chat_get_plantillas.php';
const API_BUSCAR_PAC = 'modules/turnero/api/chat_buscar_paciente.php';
const API_START_CONV = 'modules/turnero/api/chat_start_conversation.php';
let _tplSelected = null;
const BASE = (function() {
const s = window.location.pathname;
@@ -910,6 +1016,189 @@ function cancelRecording() {
document.getElementById('micBtn').style.color = '';
}
// ── Nuevo mensaje ────────────────────────────────────────────────────────────
const nuevoMensaje = (() => {
let _phone = '';
let _destLabel = '';
let _tpl = null;
let _buscarTimer = null;
function _show(stepId) {
['nv-step-dest','nv-step-tpl','nv-step-vars'].forEach(id => {
document.getElementById(id).style.display = id === stepId ? '' : 'none';
});
}
function abrir() {
_phone = ''; _destLabel = ''; _tpl = null;
document.getElementById('nv-pac-search').value = '';
document.getElementById('nv-phone-input').value = '';
document.getElementById('nv-phone-hint').textContent = '';
document.getElementById('nv-pac-results').innerHTML = '';
document.getElementById('nv-btn-siguiente').disabled = true;
_show('nv-step-dest');
document.getElementById('nuevo-modal').classList.add('show');
document.getElementById('nv-pac-search').focus();
}
function cerrar() {
document.getElementById('nuevo-modal').classList.remove('show');
}
function _setDest(phone, label) {
_phone = phone; _destLabel = label;
document.getElementById('nv-btn-siguiente').disabled = !phone;
}
function onPhoneInput(val) {
const digits = val.replace(/\D/,'');
const hint = document.getElementById('nv-phone-hint');
if (!digits) { _setDest('', ''); hint.textContent = ''; return; }
if (digits.length >= 10) {
hint.textContent = '✅ Número válido';
hint.style.color = '#16a34a';
_setDest(digits, '+' + (digits.length === 10 ? '57' : '') + digits);
} else {
hint.textContent = digits.length + ' dígitos — mínimo 10';
hint.style.color = '#94a3b8';
_setDest('', '');
}
// Clear patient selection if typing a phone
document.getElementById('nv-pac-search').value = '';
document.getElementById('nv-pac-results').innerHTML = '';
}
function buscarPaciente(q) {
document.getElementById('nv-phone-input').value = '';
document.getElementById('nv-phone-hint').textContent = '';
_setDest('', '');
clearTimeout(_buscarTimer);
const el = document.getElementById('nv-pac-results');
if (q.length < 2) { el.innerHTML = ''; return; }
el.innerHTML = '<div style="padding:8px;font-size:.8rem;color:#94a3b8"><i class="fas fa-spinner fa-spin"></i> Buscando…</div>';
_buscarTimer = setTimeout(async () => {
try {
const res = await fetch(BASE + API_BUSCAR_PAC + '?q=' + encodeURIComponent(q));
const json = await res.json();
if (!json.ok || !json.data?.length) {
el.innerHTML = '<div style="padding:8px;font-size:.8rem;color:#94a3b8">Sin resultados</div>';
return;
}
el.innerHTML = '';
json.data.forEach(p => {
const div = document.createElement('div');
div.className = 'nv-pac-item';
const tel = p.telefono || '—';
div.innerHTML =
`<span class="nv-pac-name">${escHtml(p.nombre_completo)}</span>` +
`<span class="nv-pac-sub">${escHtml(p.numero_documento || '')} · 📱 ${escHtml(tel)}</span>`;
div.onclick = () => {
const phone = (p.telefono || '').replace(/\D/g,'');
if (!phone) { alert('Este paciente no tiene teléfono registrado.'); return; }
_setDest(phone, p.nombre_completo + ' · ' + tel);
document.getElementById('nv-pac-search').value = p.nombre_completo;
el.innerHTML = '';
};
el.appendChild(div);
});
} catch(e) {
el.innerHTML = '<div style="padding:8px;font-size:.8rem;color:#ef4444">Error al buscar</div>';
}
}, 350);
}
async function irAPlantilla() {
if (!_phone) return;
document.getElementById('nv-dest-label').textContent = _destLabel || _phone;
_show('nv-step-tpl');
const listEl = document.getElementById('nv-tpl-list');
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:#94a3b8;font-size:.84rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
try {
const res = await fetch(BASE + API_PLANTILLAS);
const json = await res.json();
if (!json.ok || !json.data?.length) {
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:#94a3b8;font-size:.84rem">No hay plantillas habilitadas.<br><small>Configúralas en Configuración → Sesión.</small></div>';
return;
}
listEl.innerHTML = '';
json.data.forEach(tpl => {
const div = document.createElement('div');
div.className = 'tpl-item';
div.innerHTML =
`<span class="tpl-item-name">${escHtml(tpl.name)}</span>` +
`<span class="tpl-item-meta">${escHtml(tpl.template_name)} · ${escHtml(tpl.language_code)}</span>` +
(tpl.body_text ? `<span class="tpl-item-body">${escHtml(tpl.body_text.substring(0,80))}${tpl.body_text.length>80?'…':''}</span>` : '');
div.onclick = () => irAVariables(tpl);
listEl.appendChild(div);
});
} catch(e) {
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:#ef4444;font-size:.84rem">Error al cargar plantillas</div>';
}
}
async function irAVariables(tpl) {
_tpl = tpl;
document.getElementById('nv-vars-title').textContent = tpl.name;
const previewBox = document.getElementById('nv-preview-box');
if (tpl.body_text) { previewBox.textContent = tpl.body_text; previewBox.style.display = ''; }
else previewBox.style.display = 'none';
const varsBody = document.getElementById('nv-vars-body');
varsBody.innerHTML = '<div style="padding:6px;color:#94a3b8;font-size:.82rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
_show('nv-step-vars');
try {
const res = await fetch(BASE + 'api/get_template_details.php?id=' + tpl.id);
const json = await res.json();
const vars = json.template?.variables ?? [];
if (!vars.length) {
varsBody.innerHTML = '<div style="padding:6px;font-size:.82rem;color:#64748b">Esta plantilla no requiere variables.</div>';
} else {
varsBody.innerHTML = '';
vars.forEach(v => {
const row = document.createElement('div');
row.className = 'tpl-var-row';
const ph = v.example || v.placeholder || v.label;
row.innerHTML = `<label>{{${v.index}}} — ${escHtml(v.label)}</label>` +
`<input type="text" data-var-index="${v.index}" placeholder="${escHtml(ph)}">`;
varsBody.appendChild(row);
});
}
} catch(e) {
varsBody.innerHTML = '<div style="padding:6px;color:#ef4444;font-size:.82rem">Error al cargar variables</div>';
}
}
function volverADest() { _tpl = null; _show('nv-step-dest'); }
function volverAPlantilla() { _tpl = null; _show('nv-step-tpl'); }
async function enviar() {
if (!_phone || !_tpl) return;
const inputs = [...document.querySelectorAll('#nv-vars-body input[data-var-index]')];
const params = inputs.map(el => ({ name: el.dataset.varIndex, value: el.value.trim() }));
const missing = params.findIndex(p => !p.value);
if (missing !== -1) { inputs[missing].focus(); return; }
const templateName = _tpl.template_name;
const templateLang = _tpl.language_code;
cerrar();
try {
const res = await fetch(BASE + API_START_CONV, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: _phone, template_name: templateName, lang: templateLang, params })
});
const json = await res.json();
if (!json.ok) { alert('Error: ' + (json.error || 'No se pudo enviar')); return; }
// Abrir la conversación recién creada
await loadContacts();
if (json.user_id) openChat(json.user_id);
} catch(e) { alert('Error de conexión al enviar el mensaje'); }
}
return { abrir, cerrar, buscarPaciente, onPhoneInput, irAPlantilla, volverADest, volverAPlantilla, enviar };
})();
// ── Plantilla (2 pasos) ───────────────────────────────────────────────────────
async function openTemplatePicker() {
if (!state.activeUserId) return;