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:
Lizandro Guarnizo
2026-06-28 13:51:33 -05:00
co-authored by Claude Sonnet 4.6
parent 12ec5d28b1
commit 316d9ce73e
2 changed files with 574 additions and 215 deletions
+278 -75
View File
@@ -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];