feat(turnero): template whitelist checklist + 2-step picker in chat

- configuracion.php (sesion tab): loads approved message_templates and
  enabled turnero_chat_plantillas; renders styled checklist; guardarPlantillasChat()
  POSTs to chat_save_plantillas.php
- chat.php: replaces free-text template modal with 2-step modal —
  step 1 lists enabled templates (chat_get_plantillas.php), step 2
  fetches variables via get_template_details.php and renders labeled
  inputs; sendTemplate() builds params array and POSTs to chat_send_message.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-03 14:37:21 -05:00
co-authored by Claude Sonnet 4.6
parent 8a7e7a6658
commit 548ad3140d
2 changed files with 214 additions and 48 deletions
+141 -48
View File
@@ -228,19 +228,30 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
#recording-bar .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #e53e3e; animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
/* ── Modal plantilla ── */
#template-modal {
/* ── Modal plantilla (2 pasos) ── */
#tpl-modal {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.4); align-items: center; justify-content: center;
background: rgba(0,0,0,.45); align-items: center; justify-content: center; padding: 16px;
}
#template-modal.show { display: flex; }
.tpl-card { background: #fff; border-radius: 14px; padding: 24px; width: 340px; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
.tpl-card h3 { font-size: 1rem; font-weight: 700; margin-bottom: 16px; }
.tpl-field { margin-bottom: 12px; }
.tpl-field label { font-size: .8rem; color: #555; font-weight: 600; display: block; margin-bottom: 4px; }
.tpl-field input { width: 100%; padding: 8px 10px; border: 1.5px solid #e0e0e0; border-radius: 8px; font-size: .9rem; outline: none; }
.tpl-field input:focus { border-color: #25d366; }
.tpl-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
#tpl-modal.show { display: flex; }
.tpl-dialog { background: #fff; border-radius: 14px; width: 100%; max-width: 400px; box-shadow: 0 8px 32px rgba(0,0,0,.22); overflow: hidden; }
.tpl-hdr { display: flex; align-items: center; gap: 8px; padding: 13px 16px; border-bottom: 1px solid #f1f5f9; font-size: .92rem; font-weight: 700; color: #1e293b; }
.tpl-hdr > span { flex: 1; }
.tpl-close-btn { background: none; border: none; cursor: pointer; color: #94a3b8; font-size: .95rem; padding: 2px 7px; border-radius: 4px; }
.tpl-close-btn:hover { color: #475569; background: #f1f5f9; }
.tpl-back-btn { background: none; border: none; cursor: pointer; color: #64748b; font-size: .85rem; padding: 2px 7px; border-radius: 4px; flex-shrink: 0; }
.tpl-back-btn:hover { background: #f1f5f9; }
.tpl-item { display: flex; flex-direction: column; gap: 2px; padding: 9px 12px; cursor: pointer; border-radius: 8px; margin: 0 6px; transition: background .12s; }
.tpl-item:hover { background: #f0fdf4; }
.tpl-item-name { font-size: .87rem; font-weight: 600; color: #1e293b; }
.tpl-item-meta { font-size: .74rem; color: #64748b; }
.tpl-item-body { font-size: .74rem; color: #94a3b8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tpl-preview-box { background: #f0fdf4; border-left: 3px solid #25d366; border-radius: 0 6px 6px 0; padding: 8px 12px; font-size: .82rem; color: #374151; margin: 10px 16px 2px; white-space: pre-wrap; word-break: break-word; }
.tpl-var-row { padding: 0 16px; }
.tpl-var-row label { font-size: .78rem; font-weight: 600; color: #475569; display: block; margin-bottom: 3px; }
.tpl-var-row input { width: 100%; padding: 7px 10px; border: 1.5px solid #e2e8f0; border-radius: 8px; font-size: .87rem; outline: none; box-sizing: border-box; }
.tpl-var-row input:focus { border-color: #25d366; }
.tpl-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid #f1f5f9; }
.btn-tpl-cancel { padding: 8px 16px; background: #f5f5f5; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; }
.btn-tpl-send { padding: 8px 16px; background: #25d366; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .88rem; font-weight: 600; }
@@ -371,36 +382,41 @@ html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMa
<?php endforeach; ?>
</div>
<!-- ── Modal plantilla ── -->
<div id="template-modal">
<div class="tpl-card">
<h3><i class="fas fa-file-alt" style="color:#25d366;margin-right:6px"></i>Enviar Plantilla</h3>
<div class="tpl-field">
<label>Nombre de plantilla</label>
<input type="text" id="tplName" placeholder="ej: consentimiento_turno">
<!-- ── Modal plantilla (2 pasos) ── -->
<div id="tpl-modal">
<!-- Paso 1: lista -->
<div class="tpl-dialog" id="tpl-step-list">
<div class="tpl-hdr">
<span><i class="fas fa-file-alt" style="color:#25d366;margin-right:6px"></i>Seleccionar plantilla</span>
<button class="tpl-close-btn" onclick="closeTemplatePicker()">✕</button>
</div>
<div class="tpl-field">
<label>Idioma</label>
<input type="text" id="tplLang" value="es_CO" placeholder="es_CO">
</div>
<div class="tpl-field">
<label>Parámetros (JSON, opcional)</label>
<input type="text" id="tplParams" placeholder='[{"type":"text","text":"valor"}]'>
<div id="tpl-list-body" style="overflow-y:auto;max-height:360px;display:flex;flex-direction:column;gap:.25rem;padding:.5rem 0"></div>
</div>
<!-- Paso 2: variables -->
<div class="tpl-dialog" id="tpl-step-vars" style="display:none">
<div class="tpl-hdr">
<button class="tpl-back-btn" onclick="tplGoBack()"><i class="fas fa-arrow-left"></i></button>
<span id="tpl-vars-title">Completar variables</span>
<button class="tpl-close-btn" onclick="closeTemplatePicker()">✕</button>
</div>
<div id="tpl-preview-box" style="display:none" class="tpl-preview-box"></div>
<div id="tpl-vars-body" style="display:flex;flex-direction:column;gap:.6rem;padding:.6rem 0"></div>
<div class="tpl-actions">
<button class="btn-tpl-cancel" onclick="closeTemplatePicker()">Cancelar</button>
<button class="btn-tpl-send" onclick="sendTemplate()">Enviar</button>
<button class="btn-tpl-cancel" onclick="tplGoBack()">Atrás</button>
<button class="btn-tpl-send" onclick="sendTemplate()"><i class="fas fa-paper-plane me-1"></i>Enviar</button>
</div>
</div>
</div>
<script>
const API_LIST = 'modules/turnero/api/chat_get_list.php';
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.php';
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
const API_REACT = 'modules/turnero/api/chat_react.php';
const API_LIST = 'modules/turnero/api/chat_get_list.php';
const API_MESSAGES = 'modules/turnero/api/chat_get_messages.php';
const API_SEND = 'modules/turnero/api/chat_send_message.php';
const API_READ = 'modules/turnero/api/chat_mark_read.php';
const API_MEDIA = 'modules/turnero/api/chat_upload_media.php';
const API_REACT = 'modules/turnero/api/chat_react.php';
const API_PLANTILLAS = 'modules/turnero/api/chat_get_plantillas.php';
let _tplSelected = null;
const BASE = (function() {
const s = window.location.pathname;
return s.replace(/\/erp\.php.*$/, '/') || '/';
@@ -894,33 +910,110 @@ function cancelRecording() {
document.getElementById('micBtn').style.color = '';
}
// ── Plantilla ─────────────────────────────────────────────────────────────────
function openTemplatePicker() {
// ── Plantilla (2 pasos) ───────────────────────────────────────────────────────
async function openTemplatePicker() {
if (!state.activeUserId) return;
document.getElementById('template-modal').classList.add('show');
document.getElementById('tplName').focus();
_tplSelected = null;
document.getElementById('tpl-step-list').style.display = '';
document.getElementById('tpl-step-vars').style.display = 'none';
document.getElementById('tpl-modal').classList.add('show');
const listBody = document.getElementById('tpl-list-body');
listBody.innerHTML = '<div style="padding:20px;text-align:center;color:#94a3b8;font-size:.84rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
try {
const res = await fetch(BASE + API_PLANTILLAS);
const json = await res.json();
if (!json.ok || !json.data?.length) {
listBody.innerHTML = '<div style="padding:20px;text-align:center;color:#94a3b8;font-size:.84rem">No hay plantillas habilitadas.<br><small>Configúralas en Configuración → Sesión.</small></div>';
return;
}
listBody.innerHTML = '';
json.data.forEach(tpl => {
const div = document.createElement('div');
div.className = 'tpl-item';
div.innerHTML =
`<span class="tpl-item-name">${escHtml(tpl.name)}</span>` +
`<span class="tpl-item-meta">${escHtml(tpl.template_name)} · ${escHtml(tpl.language_code)}</span>` +
(tpl.body_text ? `<span class="tpl-item-body">${escHtml(tpl.body_text.substring(0, 80))}${tpl.body_text.length > 80 ? '…' : ''}</span>` : '');
div.onclick = () => selectTemplate(tpl);
listBody.appendChild(div);
});
} catch(e) {
listBody.innerHTML = '<div style="padding:20px;text-align:center;color:#ef4444;font-size:.84rem">Error al cargar plantillas.</div>';
}
}
function closeTemplatePicker() {
document.getElementById('template-modal').classList.remove('show');
document.getElementById('tpl-modal').classList.remove('show');
_tplSelected = null;
}
function tplGoBack() {
document.getElementById('tpl-step-list').style.display = '';
document.getElementById('tpl-step-vars').style.display = 'none';
_tplSelected = null;
}
async function selectTemplate(tpl) {
_tplSelected = tpl;
document.getElementById('tpl-step-list').style.display = 'none';
document.getElementById('tpl-step-vars').style.display = '';
document.getElementById('tpl-vars-title').textContent = tpl.name;
const previewBox = document.getElementById('tpl-preview-box');
if (tpl.body_text) {
previewBox.textContent = tpl.body_text;
previewBox.style.display = '';
} else {
previewBox.style.display = 'none';
}
const varsBody = document.getElementById('tpl-vars-body');
varsBody.innerHTML = '<div style="padding:8px 16px;color:#94a3b8;font-size:.82rem"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
try {
const res = await fetch(BASE + 'api/get_template_details.php?id=' + tpl.id);
const json = await res.json();
const vars = json.template?.variables ?? [];
if (!vars.length) {
varsBody.innerHTML = '<div style="padding:8px 16px;font-size:.82rem;color:#64748b">Esta plantilla no requiere variables.</div>';
} else {
varsBody.innerHTML = '';
vars.forEach(v => {
const row = document.createElement('div');
row.className = 'tpl-var-row';
const label = v.label || ('Variable ' + v.index);
const ph = v.example || v.placeholder || label;
row.innerHTML = `<label>{{${v.index}}} — ${escHtml(label)}</label>` +
`<input type="text" data-var-index="${v.index}" placeholder="${escHtml(ph)}">`;
varsBody.appendChild(row);
});
}
} catch(e) {
varsBody.innerHTML = '<div style="padding:8px 16px;color:#ef4444;font-size:.82rem">Error al cargar variables.</div>';
}
}
async function sendTemplate() {
const name = document.getElementById('tplName').value.trim();
const lang = document.getElementById('tplLang').value.trim() || 'es_CO';
const rawP = document.getElementById('tplParams').value.trim();
if (!name || !state.activeUserId) return alert('Ingresa el nombre de la plantilla');
let params = [];
if (rawP) {
try { params = JSON.parse(rawP); } catch(e) { return alert('Los parámetros no son JSON válido'); }
}
if (!_tplSelected || !state.activeUserId) return;
const inputs = [...document.querySelectorAll('#tpl-vars-body input[data-var-index]')];
const params = inputs.map(el => el.value.trim());
const missing = params.findIndex(p => !p);
if (missing !== -1) { inputs[missing].focus(); return; }
closeTemplatePicker();
try {
const res = await fetch(BASE + API_SEND, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: state.activeUserId, type: 'template', template: name, lang, params })
body: JSON.stringify({
user_id: state.activeUserId,
type: 'template',
template: _tplSelected.template_name,
lang: _tplSelected.language_code,
params
})
});
const json = await res.json();
if (!json.success) alert('Error al enviar plantilla: ' + (json.error || ''));
+73
View File
@@ -91,6 +91,22 @@ try {
foreach ($cfgRows as $r) { $cfg[$r['clave']] = $r['valor']; }
} catch (\Throwable $e) { $_loadErrors[] = 'lab_config: ' . $e->getMessage(); }
$plantillasAprobadas = [];
$plantillasHabilitadas = [];
try {
$plantillasAprobadas = $pdo->query(
"SELECT id, name, template_name, language_code, body_text
FROM message_templates WHERE status='approved' ORDER BY name ASC"
)->fetchAll(PDO::FETCH_ASSOC);
$pdo->exec("CREATE TABLE IF NOT EXISTS turnero_chat_plantillas (
template_id INT NOT NULL, PRIMARY KEY (template_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
$plantillasHabilitadas = array_column(
$pdo->query("SELECT template_id FROM turnero_chat_plantillas")->fetchAll(PDO::FETCH_ASSOC),
'template_id'
);
} catch (\Throwable $e) { $_loadErrors[] = 'plantillas_chat: ' . $e->getMessage(); }
$tab = $_GET['tab'] ?? 'lugares';
?>
<!DOCTYPE html>
@@ -127,6 +143,14 @@ $tab = $_GET['tab'] ?? 'lugares';
.session-open { background:#dcfce7; color:#15803d; }
.session-closed{ background:#f1f5f9; color:#64748b; }
.consent-tag { font-size:.7rem; background:#ede9fe; color:#5b21b6; border-radius:99px; padding:2px 8px; white-space:normal; word-break:break-word; max-width:260px; }
.plantilla-chk-row { display:flex; align-items:flex-start; gap:.65rem; padding:.6rem .8rem; border:1.5px solid #e2e8f0; border-radius:8px; cursor:pointer; background:#fff; transition:border-color .15s,background .15s; }
.plantilla-chk-row:hover { background:#f8fafc; border-color:#cbd5e1; }
.plantilla-chk-row.is-checked { border-color:#3b82f6; background:#eff6ff; }
.plantilla-chk-row input[type=checkbox] { margin-top:.2rem; flex-shrink:0; accent-color:#3b82f6; }
.plantilla-chk-body { display:flex; flex-direction:column; gap:.1rem; min-width:0; }
.plantilla-chk-name { font-size:.875rem; font-weight:600; color:#1e293b; }
.plantilla-chk-meta { font-size:.75rem; color:#64748b; }
.plantilla-chk-preview { font-size:.75rem; color:#94a3b8; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:480px; }
</style>
</head>
<body>
@@ -892,6 +916,41 @@ $tab = $_GET['tab'] ?? 'lugares';
</div>
</div>
<!-- Plantillas habilitadas en el chat turnero -->
<div class="mt-4">
<p class="section-title"><i class="fas fa-comment-dots me-1"></i>Plantillas habilitadas en el chat</p>
<p class="text-muted small mb-3">Marca las plantillas aprobadas disponibles en el chat del turnero.</p>
<?php if (empty($plantillasAprobadas)): ?>
<div class="alert alert-warning py-2 px-3" style="font-size:.85rem">
<i class="fas fa-exclamation-triangle me-1"></i>
No hay plantillas aprobadas. Sincroniza primero desde WhatsApp Business.
</div>
<?php else: ?>
<div id="plantillas-chat-list" style="display:grid;gap:.4rem;margin-bottom:.75rem">
<?php foreach ($plantillasAprobadas as $pt):
$checked = in_array((int)$pt['id'], array_map('intval', $plantillasHabilitadas));
?>
<label class="plantilla-chk-row<?= $checked ? ' is-checked' : '' ?>">
<input type="checkbox" value="<?= (int)$pt['id'] ?>" <?= $checked ? 'checked' : '' ?>
onchange="this.closest('label').classList.toggle('is-checked',this.checked)">
<div class="plantilla-chk-body">
<span class="plantilla-chk-name"><?= htmlspecialchars($pt['name']) ?></span>
<span class="plantilla-chk-meta"><?= htmlspecialchars($pt['template_name']) ?> &bull; <?= htmlspecialchars($pt['language_code']) ?></span>
<?php if (!empty($pt['body_text'])): ?>
<span class="plantilla-chk-preview"><?= htmlspecialchars(mb_substr($pt['body_text'], 0, 100)) . (mb_strlen($pt['body_text']) > 100 ? '…' : '') ?></span>
<?php endif; ?>
</div>
</label>
<?php endforeach; ?>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-primary btn-sm" onclick="guardarPlantillasChat()">
<i class="fas fa-save me-1"></i>Guardar plantillas habilitadas
</button>
</div>
<?php endif; ?>
</div>
<!-- ════════════════════════════════════════
TAB 5 — PANTALLA TV
════════════════════════════════════════ -->
@@ -1358,6 +1417,20 @@ async function subirFotoPerfil(input) {
}
}
async function guardarPlantillasChat() {
const ids = [...document.querySelectorAll('#plantillas-chat-list input[type=checkbox]:checked')]
.map(el => parseInt(el.value, 10));
try {
const res = await fetch(API + 'chat_save_plantillas.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
const json = await res.json();
if (!json.ok) { toast(json.error || 'Error al guardar', 'error'); return; }
toast('Plantillas guardadas (' + ids.length + ')');
} catch (e) { toast('Error de conexión', 'error'); }
}
async function guardarWhatsApp() {
const template = document.getElementById('wa-template')?.value.trim();
const templateMuestra = document.getElementById('wa-template-muestra')?.value.trim();