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 || ''));