diff --git a/modules/turnero/api/chat_buscar_paciente.php b/modules/turnero/api/chat_buscar_paciente.php
new file mode 100644
index 0000000..0701871
--- /dev/null
+++ b/modules/turnero/api/chat_buscar_paciente.php
@@ -0,0 +1,29 @@
+ 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()]);
+}
diff --git a/modules/turnero/api/chat_start_conversation.php b/modules/turnero/api/chat_start_conversation.php
new file mode 100644
index 0000000..a3c9ee2
--- /dev/null
+++ b/modules/turnero/api/chat_start_conversation.php
@@ -0,0 +1,81 @@
+ 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()]);
+}
diff --git a/modules/turnero/views/chat.php b/modules/turnero/views/chat.php
index 2bb791f..9aa0413 100644
--- a/modules/turnero/views/chat.php
+++ b/modules/turnero/views/chat.php
@@ -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; }
}
@@ -295,6 +333,9 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
Sin config
+
@@ -382,6 +423,69 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
+
+
+
+
+
+ Nuevo mensaje
+
+
+
+
+
+
o ingresa el número directamente
+
+
+
+
+
+
+
+
+
+ Seleccionar plantilla
+
+
+
+
+
+
+
+
+ Completar variables
+
+
+
+
+
+
+
@@ -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 = '
Buscando…
';
+ _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 = '
Sin resultados
';
+ return;
+ }
+ el.innerHTML = '';
+ json.data.forEach(p => {
+ const div = document.createElement('div');
+ div.className = 'nv-pac-item';
+ const tel = p.telefono || '—';
+ div.innerHTML =
+ `
${escHtml(p.nombre_completo)}` +
+ `
${escHtml(p.numero_documento || '')} · 📱 ${escHtml(tel)}`;
+ 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 = '
Error al buscar
';
+ }
+ }, 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 = '
Cargando…
';
+ try {
+ const res = await fetch(BASE + API_PLANTILLAS);
+ const json = await res.json();
+ if (!json.ok || !json.data?.length) {
+ listEl.innerHTML = '
No hay plantillas habilitadas.
Configúralas en Configuración → Sesión.
';
+ return;
+ }
+ listEl.innerHTML = '';
+ json.data.forEach(tpl => {
+ const div = document.createElement('div');
+ div.className = 'tpl-item';
+ div.innerHTML =
+ `
${escHtml(tpl.name)}` +
+ `
${escHtml(tpl.template_name)} · ${escHtml(tpl.language_code)}` +
+ (tpl.body_text ? `
${escHtml(tpl.body_text.substring(0,80))}${tpl.body_text.length>80?'…':''}` : '');
+ div.onclick = () => irAVariables(tpl);
+ listEl.appendChild(div);
+ });
+ } catch(e) {
+ listEl.innerHTML = '
Error al cargar plantillas
';
+ }
+ }
+
+ 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 = '
Cargando…
';
+ _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 = '
Esta plantilla no requiere variables.
';
+ } 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 = `
` +
+ `
`;
+ varsBody.appendChild(row);
+ });
+ }
+ } catch(e) {
+ varsBody.innerHTML = '
Error al cargar variables
';
+ }
+ }
+
+ 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;