feat: dynamic lists, multi-step forms, improved flow editor
NormalBot new flow types: - collect_input: asks user a question, saves answer to metadata - dynamic_list: calls endpoint to get options, displays WhatsApp list, saves selection to metadata; max 10 rows - submit_form: reads accumulated metadata fields and sends to api_report Commands always take priority (escape from any collecting state). Interactive selections during collecting state are caught before the static menu lookup. Admin flow editor redesign: - All fields use select dropdowns (menus, endpoints, next-node) - Grouped options: Respuesta / Navegar / Acción directa / Formulario paso a paso - collect_input, dynamic_list, submit_form panels with contextual fields - addNewFlow() opens blank modal; saveFlow() handles all types Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
12ec5d28b1
commit
316d9ce73e
+278
-75
@@ -2582,7 +2582,7 @@ HTML;
|
||||
}
|
||||
echo <<<HTML
|
||||
</div>
|
||||
<button type="button" class="add-btn" onclick="addFlow()">+ Agregar flujo</button>
|
||||
<button type="button" class="add-btn" onclick="addNewFlow()">+ Agregar flujo</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── POR CATEGORÍA ────────────────────────────────────────────────── -->
|
||||
@@ -3298,95 +3298,264 @@ function deleteMenu(key) {
|
||||
}
|
||||
|
||||
// ─── Edit Flow ───
|
||||
function buildFlowModal(key, flow) {
|
||||
const isNew = !key;
|
||||
const menuOpts = Object.keys(CONFIG.menus).map(m =>
|
||||
'<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>📑 ' + m + '</option>'
|
||||
).join('');
|
||||
|
||||
const allTargets = [
|
||||
...Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>📑 Menú: ' + m + '</option>'),
|
||||
].join('');
|
||||
|
||||
const epOpts = Object.keys(ENDPOINTS).map(k =>
|
||||
'<option value="' + k + '"' + (((flow.params||{}).endpoint_key) === k ? ' selected' : '') +
|
||||
'>' + k + '</option>'
|
||||
).join('');
|
||||
|
||||
// Dynamic-list source endpoint
|
||||
const dlEpOpts = Object.keys(ENDPOINTS).map(k =>
|
||||
'<option value="' + k + '"' + ((flow.source_endpoint_key) === k ? ' selected' : '') +
|
||||
'>' + k + '</option>'
|
||||
).join('');
|
||||
|
||||
// Next-node options (other flows)
|
||||
const flowOpts = Object.keys(CONFIG.flows).filter(f => f !== key).map(f =>
|
||||
'<option value="' + f + '">' + f + '</option>'
|
||||
).join('');
|
||||
|
||||
const t = flow.type || 'text';
|
||||
const fn = flow.function || '';
|
||||
const dateMode = (flow.params||{}).date_mode || '';
|
||||
const caption = (flow.params||{}).caption || '';
|
||||
const filename = (flow.params||{}).filename || '';
|
||||
const metaGroup = flow.meta_group || '';
|
||||
const metaKey = flow.meta_key || '';
|
||||
const nextNode = flow.next_node || '';
|
||||
const prompt = flow.prompt || '';
|
||||
const header = flow.header || '';
|
||||
const body = flow.body || '';
|
||||
const secTitle = flow.section_title || '';
|
||||
const valField = flow.value_field || 'id';
|
||||
const lblField = flow.label_field || 'nombre';
|
||||
|
||||
const showFn = t === 'function';
|
||||
const showEp = t === 'function' && fn === 'api_report';
|
||||
const showCollect = t === 'collect_input';
|
||||
const showDynList = t === 'dynamic_list';
|
||||
const showSubmit = t === 'submit_form';
|
||||
|
||||
return \`
|
||||
<h2>\${isNew ? '➕ Nuevo Flujo' : '🔀 Editar Flujo: ' + key}</h2>
|
||||
<div class="form-group">
|
||||
<label>ID del flujo</label>
|
||||
<input type="text" id="editFlowKey" value="\${key}" placeholder="ej: ver_cosecha">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>¿Qué hace este flujo?</label>
|
||||
<select id="editFlowType" onchange="flowTypeToggle()">
|
||||
<optgroup label="Respuesta">
|
||||
<option value="text" \${t==='text' ?'selected':''}>💬 Texto fijo</option>
|
||||
</optgroup>
|
||||
<optgroup label="Navegar">
|
||||
<option value="menu" \${t==='menu' ?'selected':''}>📑 Mostrar menú</option>
|
||||
</optgroup>
|
||||
<optgroup label="Acción directa">
|
||||
<option value="function" \${t==='function' ?'selected':''}>⚡ Función especial</option>
|
||||
</optgroup>
|
||||
<optgroup label="Formulario paso a paso">
|
||||
<option value="collect_input"\${t==='collect_input'?'selected':''}>✏️ Pedir dato al usuario</option>
|
||||
<option value="dynamic_list" \${t==='dynamic_list' ?'selected':''}>📋 Lista dinámica (desde endpoint)</option>
|
||||
<option value="submit_form" \${t==='submit_form' ?'selected':''}>🚀 Enviar formulario a endpoint</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- texto fijo -->
|
||||
<div id="fgText" style="\${t==='text'?'':'display:none'}">
|
||||
<div class="form-group"><label>Mensaje</label>
|
||||
<textarea id="editFlowMessage" rows="3" placeholder="Texto que recibirá el usuario">\${flow.message||''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- menú -->
|
||||
<div id="fgMenu" style="\${t==='menu'?'':'display:none'}">
|
||||
<div class="form-group"><label>Menú a mostrar</label>
|
||||
<select id="editFlowMenu"><option value="">— Selecciona menú —</option>\${menuOpts}</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- función especial -->
|
||||
<div id="fgFunc" style="\${showFn?'':'display:none'}">
|
||||
<div class="form-group"><label>Función</label>
|
||||
<select id="editFlowFunction" onchange="flowFuncToggle()">
|
||||
<option value="goodbye" \${fn==='goodbye' ?'selected':''}>↩️ Salir / Goodbye</option>
|
||||
<option value="api_report" \${fn==='api_report' ?'selected':''}>📄 Descargar informe (api_report)</option>
|
||||
<option value="forward_to_ai" \${fn==='forward_to_ai' ?'selected':''}>🤖 Escalar a IA</option>
|
||||
<option value="forward_to_agent"\${fn==='forward_to_agent'?'selected':''}>👤 Escalar a agente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="fgEp" style="\${showEp?'':'display:none'}">
|
||||
<div class="form-group"><label>Endpoint</label>
|
||||
<select id="editFlowEpKey">
|
||||
<option value="">— Selecciona —</option>\${epOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Periodo de fecha</label>
|
||||
<select id="editFlowDateMode">
|
||||
<option value="" \${dateMode==='' ?'selected':''}>Sin fecha</option>
|
||||
<option value="today" \${dateMode==='today' ?'selected':''}>Hoy</option>
|
||||
<option value="last_30"\${dateMode==='last_30'?'selected':''}>Últimos 30 días</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Nombre archivo</label>
|
||||
<input type="text" id="editFlowFilename" value="\${filename}" placeholder="reporte.pdf">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Mensaje al enviar</label>
|
||||
<input type="text" id="editFlowCaption" value="\${caption}" placeholder="Aquí tienes el informe.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- collect_input -->
|
||||
<div id="fgCollect" style="\${showCollect?'':'display:none'}">
|
||||
<div class="form-group"><label>Pregunta al usuario</label>
|
||||
<input type="text" id="editFlowPrompt" value="\${prompt}" placeholder="¿Desde qué fecha? (YYYY-MM-DD)">
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Guardar en grupo</label>
|
||||
<input type="text" id="editFlowMetaGroup" value="\${metaGroup}" placeholder="form_produccion_lotes">
|
||||
</div>
|
||||
<div><label>Guardar en campo</label>
|
||||
<input type="text" id="editFlowMetaKey" value="\${metaKey}" placeholder="desde">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Flujo siguiente</label>
|
||||
<select id="editFlowNextNode">
|
||||
<option value="">— Ninguno (terminar) —</option>\${flowOpts}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- dynamic_list -->
|
||||
<div id="fgDynList" style="\${showDynList?'':'display:none'}">
|
||||
<div class="form-group"><label>Endpoint que devuelve la lista</label>
|
||||
<select id="editFlowDlEp">
|
||||
<option value="">— Selecciona endpoint —</option>\${dlEpOpts}
|
||||
</select>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Debe devolver un array JSON: [{id, nombre}, ...]</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Campo de valor (ID)</label>
|
||||
<input type="text" id="editFlowValField" value="\${valField}" placeholder="id">
|
||||
</div>
|
||||
<div><label>Campo de etiqueta</label>
|
||||
<input type="text" id="editFlowLblField" value="\${lblField}" placeholder="nombre">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Encabezado lista</label>
|
||||
<input type="text" id="editFlowHeader" value="\${header}" placeholder="Selecciona la finca">
|
||||
</div>
|
||||
<div><label>Título sección</label>
|
||||
<input type="text" id="editFlowSecTitle" value="\${secTitle}" placeholder="Fincas">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Cuerpo del mensaje</label>
|
||||
<input type="text" id="editFlowBody" value="\${body}" placeholder="¿De qué finca necesitas el informe?">
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Guardar en grupo</label>
|
||||
<input type="text" id="editFlowDlMetaGroup" value="\${metaGroup}" placeholder="form_produccion_finca">
|
||||
</div>
|
||||
<div><label>Guardar en campo</label>
|
||||
<input type="text" id="editFlowDlMetaKey" value="\${metaKey}" placeholder="finca_id">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Flujo siguiente</label>
|
||||
<select id="editFlowDlNext">
|
||||
<option value="">— Ninguno —</option>\${flowOpts}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- submit_form -->
|
||||
<div id="fgSubmit" style="\${showSubmit?'':'display:none'}">
|
||||
<div class="form-group"><label>Endpoint destino</label>
|
||||
<select id="editFlowSubmitEp">
|
||||
<option value="">— Selecciona endpoint —</option>\${epOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Grupo del formulario (debe coincidir con collect_input / dynamic_list)</label>
|
||||
<input type="text" id="editFlowSubmitGroup" value="\${metaGroup}" placeholder="form_produccion_lotes">
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Nombre archivo</label>
|
||||
<input type="text" id="editFlowSubmitFilename" value="\${filename}" placeholder="reporte.pdf">
|
||||
</div>
|
||||
<div><label>Mensaje al enviar</label>
|
||||
<input type="text" id="editFlowSubmitCaption" value="\${caption}" placeholder="Aquí tienes el informe.">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
\${isNew ? '' : '<button class="btn-del-modal" onclick="deleteFlow(\'' + key + '\')">Eliminar</button>'}
|
||||
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
|
||||
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function editFlow(key) {
|
||||
const flow = CONFIG.flows[key];
|
||||
if (!flow) return;
|
||||
const menuOpts = Object.keys(CONFIG.menus).map(m => '<option value="' + m + '"' + (flow.menu === m ? ' selected' : '') + '>' + m + '</option>').join('');
|
||||
const epOpts = Object.keys(ENDPOINTS).map(k => '<option value="' + k + '"' + (((flow.params||{}).endpoint_key) === k ? ' selected' : '') + '>' + k + ' — ' + (ENDPOINTS[k].url||'').slice(0,50) + '</option>').join('');
|
||||
const isApiReport = flow.function === 'api_report';
|
||||
const dateMode = (flow.params||{}).date_mode || '';
|
||||
const caption = (flow.params||{}).caption || '';
|
||||
const filename = (flow.params||{}).filename || '';
|
||||
showModal(\`
|
||||
<h2>🔀 Editar Flujo: \${key}</h2>
|
||||
<div class="form-group"><label>ID del flujo</label><input type="text" id="editFlowKey" value="\${key}"></div>
|
||||
<div class="form-group"><label>Tipo</label>
|
||||
<select id="editFlowType" onchange="flowTypeToggle()">
|
||||
<option value="text"\${flow.type==='text'?' selected':''}>Texto (respuesta fija)</option>
|
||||
<option value="function"\${flow.type==='function'?' selected':''}>Función (acción especial)</option>
|
||||
<option value="menu"\${flow.type==='menu'?' selected':''}>Menú (submenú)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="flowTextGroup" style="\${flow.type==='text'?'':'display:none'}">
|
||||
<label>Mensaje de respuesta</label>
|
||||
<textarea id="editFlowMessage" rows="3">\${flow.message||''}</textarea>
|
||||
</div>
|
||||
<div class="form-group" id="flowFuncGroup" style="\${flow.type==='function'?'':'display:none'}">
|
||||
<label>Función</label>
|
||||
<select id="editFlowFunction" onchange="flowFuncToggle()">
|
||||
<option value="forward_to_ai"\${flow.function==='forward_to_ai'?' selected':''}>Escalar a IA</option>
|
||||
<option value="forward_to_agent"\${flow.function==='forward_to_agent'?' selected':''}>Escalar a agente humano</option>
|
||||
<option value="api_report"\${flow.function==='api_report'?' selected':''}>Descargar / enviar informe</option>
|
||||
<option value="goodbye"\${flow.function==='goodbye'?' selected':''}>Salir (goodbye)</option>
|
||||
<option value="webhook"\${flow.function==='webhook'?' selected':''}>Llamar webhook externo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="flowEpGroup" style="\${isApiReport?'':'display:none'}">
|
||||
<div class="form-group">
|
||||
<label>Endpoint</label>
|
||||
<select id="editFlowEpKey">
|
||||
<option value="">— Selecciona endpoint —</option>\${epOpts}
|
||||
</select>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Configura los endpoints en la pestaña "Endpoints API" de la empresa</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div><label>Periodo</label>
|
||||
<select id="editFlowDateMode">
|
||||
<option value="" \${dateMode===''?'selected':''}>Sin fecha</option>
|
||||
<option value="today" \${dateMode==='today'?'selected':''}>Hoy</option>
|
||||
<option value="last_30" \${dateMode==='last_30'?'selected':''}>Últimos 30 días</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label>Nombre archivo</label>
|
||||
<input type="text" id="editFlowFilename" value="\${filename}" placeholder="reporte.pdf">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Mensaje al enviar</label>
|
||||
<input type="text" id="editFlowCaption" value="\${caption}" placeholder="Aquí tienes el informe solicitado.">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="flowMenuGroup" style="\${flow.type==='menu'?'':'display:none'}">
|
||||
<label>Sub-menú a mostrar</label>
|
||||
<select id="editFlowMenu"><option value="">Selecciona...</option>\${menuOpts}</select>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-cancel" onclick="hideModal()">Cancelar</button>
|
||||
<button class="btn-del-modal" onclick="deleteFlow('\${key}')">Eliminar</button>
|
||||
<button class="btn-save" onclick="saveFlow('\${key}')">Guardar</button>
|
||||
</div>\`);
|
||||
document.getElementById('modalContent').innerHTML = buildFlowModal(key, flow);
|
||||
document.getElementById('modalOverlay').classList.add('show');
|
||||
// Restore next-node selection for collect_input
|
||||
const nn = flow.next_node || '';
|
||||
const nnSel = document.getElementById('editFlowNextNode');
|
||||
if (nnSel) { for (const o of nnSel.options) if (o.value === nn) { o.selected = true; break; } }
|
||||
const dnSel = document.getElementById('editFlowDlNext');
|
||||
if (dnSel) { for (const o of dnSel.options) if (o.value === flow.next_node||'') { o.selected = true; break; } }
|
||||
}
|
||||
|
||||
function addNewFlow() {
|
||||
document.getElementById('modalContent').innerHTML = buildFlowModal('', {type:'text'});
|
||||
document.getElementById('modalOverlay').classList.add('show');
|
||||
}
|
||||
|
||||
function flowTypeToggle() {
|
||||
const t = document.getElementById('editFlowType').value;
|
||||
document.getElementById('flowTextGroup').style.display = t === 'text' ? '' : 'none';
|
||||
document.getElementById('flowFuncGroup').style.display = t === 'function' ? '' : 'none';
|
||||
document.getElementById('flowMenuGroup').style.display = t === 'menu' ? '' : 'none';
|
||||
if (t !== 'function') document.getElementById('flowEpGroup').style.display = 'none';
|
||||
document.getElementById('fgText').style.display = t === 'text' ? '' : 'none';
|
||||
document.getElementById('fgMenu').style.display = t === 'menu' ? '' : 'none';
|
||||
document.getElementById('fgFunc').style.display = t === 'function' ? '' : 'none';
|
||||
document.getElementById('fgCollect').style.display = t === 'collect_input' ? '' : 'none';
|
||||
document.getElementById('fgDynList').style.display = t === 'dynamic_list' ? '' : 'none';
|
||||
document.getElementById('fgSubmit').style.display = t === 'submit_form' ? '' : 'none';
|
||||
if (t !== 'function') document.getElementById('fgEp').style.display = 'none';
|
||||
}
|
||||
|
||||
function flowFuncToggle() {
|
||||
const fn = document.getElementById('editFlowFunction').value;
|
||||
document.getElementById('flowEpGroup').style.display = fn === 'api_report' ? '' : 'none';
|
||||
document.getElementById('fgEp').style.display = fn === 'api_report' ? '' : 'none';
|
||||
}
|
||||
|
||||
function saveFlow(oldKey) {
|
||||
const newKey = document.getElementById('editFlowKey').value.trim();
|
||||
const type = document.getElementById('editFlowType').value;
|
||||
if (!newKey) { alert('El ID del flujo es requerido'); return; }
|
||||
const type = document.getElementById('editFlowType').value;
|
||||
const flow = { type };
|
||||
if (type === 'text') flow.message = document.getElementById('editFlowMessage').value.trim();
|
||||
else if (type === 'function') {
|
||||
|
||||
if (type === 'text') {
|
||||
flow.message = document.getElementById('editFlowMessage').value.trim();
|
||||
|
||||
} else if (type === 'menu') {
|
||||
flow.menu = document.getElementById('editFlowMenu').value;
|
||||
|
||||
} else if (type === 'function') {
|
||||
flow.function = document.getElementById('editFlowFunction').value;
|
||||
if (flow.function === 'api_report') {
|
||||
flow.params = {
|
||||
@@ -3396,10 +3565,44 @@ function saveFlow(oldKey) {
|
||||
filename: document.getElementById('editFlowFilename').value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
} else if (type === 'collect_input') {
|
||||
flow.prompt = document.getElementById('editFlowPrompt').value.trim();
|
||||
flow.meta_group = document.getElementById('editFlowMetaGroup').value.trim();
|
||||
flow.meta_key = document.getElementById('editFlowMetaKey').value.trim();
|
||||
flow.next_node = document.getElementById('editFlowNextNode').value || null;
|
||||
|
||||
} else if (type === 'dynamic_list') {
|
||||
flow.source_endpoint_key = document.getElementById('editFlowDlEp').value;
|
||||
flow.value_field = document.getElementById('editFlowValField').value.trim() || 'id';
|
||||
flow.label_field = document.getElementById('editFlowLblField').value.trim() || 'nombre';
|
||||
flow.header = document.getElementById('editFlowHeader').value.trim();
|
||||
flow.body = document.getElementById('editFlowBody').value.trim();
|
||||
flow.section_title = document.getElementById('editFlowSecTitle').value.trim();
|
||||
flow.meta_group = document.getElementById('editFlowDlMetaGroup').value.trim();
|
||||
flow.meta_key = document.getElementById('editFlowDlMetaKey').value.trim();
|
||||
flow.next_node = document.getElementById('editFlowDlNext').value || null;
|
||||
|
||||
} else if (type === 'submit_form') {
|
||||
flow.endpoint_key = document.getElementById('editFlowSubmitEp').value;
|
||||
flow.meta_group = document.getElementById('editFlowSubmitGroup').value.trim();
|
||||
flow.filename = document.getElementById('editFlowSubmitFilename').value.trim();
|
||||
flow.caption = document.getElementById('editFlowSubmitCaption').value.trim();
|
||||
}
|
||||
else if (type === 'menu') flow.menu = document.getElementById('editFlowMenu').value;
|
||||
delete CONFIG.flows[oldKey];
|
||||
|
||||
if (oldKey && oldKey !== newKey) delete CONFIG.flows[oldKey];
|
||||
CONFIG.flows[newKey] = flow;
|
||||
// Sync menu row IDs if key changed
|
||||
for (const mk in CONFIG.menus) {
|
||||
const menu = CONFIG.menus[mk];
|
||||
for (const s of (menu.sections || [])) {
|
||||
for (const r of (s.rows || [])) {
|
||||
if (r.id === oldKey && oldKey !== newKey) r.id = newKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveConfig();
|
||||
}
|
||||
// Update menu rows that reference this flow
|
||||
for (const mk in CONFIG.menus) {
|
||||
const menu = CONFIG.menus[mk];
|
||||
|
||||
Reference in New Issue
Block a user