From bd63d92c500af06329d0c6ea5e800b61908f8308 Mon Sep 17 00:00:00 2001
From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com>
Date: Sun, 25 Jan 2026 22:32:58 -0300
Subject: [PATCH] modificando el patron del sistema, orientado mas al usuario
---
public/components/aliases-crud.js | 14 +-
public/components/chat-simulator.js | 3 +-
public/components/conversation-list.js | 10 +-
public/components/conversations-crud.js | 10 +-
public/components/products-crud.js | 22 +-
public/components/prompts-crud.js | 27 ++-
public/components/recommendations-crud.js | 18 +-
public/components/takeovers-crud.js | 210 ++++++++++++++++++-
public/components/test-panel.js | 21 +-
public/components/users-crud.js | 13 +-
public/lib/modal.js | 232 +++++++++++++++++++++
src/modules/0-ui/controllers/takeovers.js | 3 +-
src/modules/0-ui/handlers/takeovers.js | 58 +++++-
src/modules/3-turn-engine/orderModel.js | 6 +-
src/modules/3-turn-engine/stateHandlers.js | 143 ++++++++++++-
15 files changed, 707 insertions(+), 83 deletions(-)
create mode 100644 public/lib/modal.js
diff --git a/public/components/aliases-crud.js b/public/components/aliases-crud.js
index 8b1c054..288c423 100644
--- a/public/components/aliases-crud.js
+++ b/public/components/aliases-crud.js
@@ -1,4 +1,5 @@
import { api } from "../lib/api.js";
+import { modal } from "../lib/modal.js";
class AliasesCrud extends HTMLElement {
constructor() {
@@ -380,7 +381,7 @@ class AliasesCrud extends HTMLElement {
addBtn.onclick = () => {
if (!selectedProductId) {
- alert("Selecciona un producto primero");
+ modal.warn("Selecciona un producto primero");
return;
}
@@ -407,12 +408,12 @@ class AliasesCrud extends HTMLElement {
const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
if (!aliasInput) {
- alert("El alias es requerido");
+ modal.warn("El alias es requerido");
return;
}
if (!this.productMappings.length) {
- alert("Agrega al menos un producto");
+ modal.warn("Agrega al menos un producto");
return;
}
@@ -441,13 +442,14 @@ class AliasesCrud extends HTMLElement {
this.renderForm();
} catch (e) {
console.error("Error saving alias:", e);
- alert("Error guardando: " + (e.message || e));
+ modal.error("Error guardando: " + (e.message || e));
}
}
async delete() {
if (!this.selected?.alias) return;
- if (!confirm(`¿Eliminar el alias "${this.selected.alias}"?`)) return;
+ const confirmed = await modal.confirm(`¿Eliminar el alias "${this.selected.alias}"?`);
+ if (!confirmed) return;
try {
await api.deleteAlias(this.selected.alias);
@@ -458,7 +460,7 @@ class AliasesCrud extends HTMLElement {
this.renderForm();
} catch (e) {
console.error("Error deleting alias:", e);
- alert("Error eliminando: " + (e.message || e));
+ modal.error("Error eliminando: " + (e.message || e));
}
}
diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js
index 03e6163..73aec1f 100644
--- a/public/components/chat-simulator.js
+++ b/public/components/chat-simulator.js
@@ -1,5 +1,6 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
+import { modal } from "../lib/modal.js";
class ChatSimulator extends HTMLElement {
constructor() {
@@ -129,7 +130,7 @@ class ChatSimulator extends HTMLElement {
const pushName = evoPushEl.value.trim();
if (!from || !text) {
- alert("Falta from o text");
+ modal.warn("Falta from o text");
return;
}
diff --git a/public/components/conversation-list.js b/public/components/conversation-list.js
index 34b73aa..1411815 100644
--- a/public/components/conversation-list.js
+++ b/public/components/conversation-list.js
@@ -1,5 +1,6 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
+import { modal } from "../lib/modal.js";
class ConversationList extends HTMLElement {
constructor() {
@@ -90,7 +91,8 @@ class ConversationList extends HTMLElement {
this.shadowRoot.getElementById("uaDeleteConv").onclick = async () => {
if (!this.selectedUser) return;
const chat_id = this.selectedUser.chat_id;
- if (!confirm(`¿Borrar conversación completa de ${chat_id}?`)) return;
+ const confirmed = await modal.confirm(`¿Borrar conversación completa de ${chat_id}?`);
+ if (!confirmed) return;
await api.deleteConversation(chat_id);
this.selectedUser = null;
await this.refreshUsers();
@@ -98,7 +100,8 @@ class ConversationList extends HTMLElement {
this.shadowRoot.getElementById("uaDeleteUser").onclick = async () => {
if (!this.selectedUser) return;
const chat_id = this.selectedUser.chat_id;
- if (!confirm(`¿Borrar usuario ${chat_id}, sus conversaciones y el customer en Woo?`)) return;
+ const confirmed = await modal.confirm(`¿Borrar usuario ${chat_id}, sus conversaciones y el customer en Woo?`);
+ if (!confirmed) return;
await api.deleteUser(chat_id, { deleteWoo: true });
this.selectedUser = null;
await this.refreshUsers();
@@ -229,7 +232,8 @@ class ConversationList extends HTMLElement {
el.onclick = async (e) => {
if (e?.target?.dataset?.del) {
e.stopPropagation();
- if (!confirm(`¿Borrar conversación completa de ${c.chat_id}?`)) return;
+ const confirmed = await modal.confirm(`¿Borrar conversación completa de ${c.chat_id}?`);
+ if (!confirmed) return;
await api.deleteConversation(c.chat_id);
if (this.selected === c.chat_id) this.selected = null;
await this.refresh();
diff --git a/public/components/conversations-crud.js b/public/components/conversations-crud.js
index 77d05e5..0919d51 100644
--- a/public/components/conversations-crud.js
+++ b/public/components/conversations-crud.js
@@ -1,5 +1,6 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
+import { modal } from "../lib/modal.js";
class ConversationsCrud extends HTMLElement {
constructor() {
@@ -244,21 +245,22 @@ class ConversationsCrud extends HTMLElement {
this.shadowRoot.getElementById("retryLast").onclick = async () => {
try {
await api.retryLast(c.chat_id);
- alert("Retry ejecutado");
+ modal.success("Retry ejecutado");
} catch (e) {
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
};
this.shadowRoot.getElementById("deleteConv").onclick = async () => {
- if (!confirm(`¿Eliminar la conversacion de "${c.chat_id}"?`)) return;
+ const confirmed = await modal.confirm(`¿Eliminar la conversación de "${c.chat_id}"?`);
+ if (!confirmed) return;
try {
await api.deleteConversation(c.chat_id);
this.selected = null;
await this.load();
this.renderDetail();
} catch (e) {
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
};
}
diff --git a/public/components/products-crud.js b/public/components/products-crud.js
index 1a1e2fe..002c205 100644
--- a/public/components/products-crud.js
+++ b/public/components/products-crud.js
@@ -1,6 +1,7 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
import { navigateToItem } from "../lib/router.js";
+import { modal } from "../lib/modal.js";
class ProductsCrud extends HTMLElement {
constructor() {
@@ -166,13 +167,10 @@ class ProductsCrud extends HTMLElement {
async syncFromWoo() {
// Mostrar confirmación antes de sincronizar
- const confirmed = confirm(
+ const confirmed = await modal.confirm(
"⚠️ Resincronización de emergencia\n\n" +
"Esto reimportará TODOS los productos desde WooCommerce y sobrescribirá los datos locales.\n\n" +
- "Usar solo si:\n" +
- "• La plataforma estuvo caída mientras se hacían cambios en Woo\n" +
- "• Los webhooks no funcionaron correctamente\n" +
- "• Necesitás una sincronización completa\n\n" +
+ "Usar solo si la plataforma estuvo caída, los webhooks no funcionaron, o necesitás una sincronización completa.\n\n" +
"¿Continuar?"
);
@@ -185,14 +183,14 @@ class ProductsCrud extends HTMLElement {
try {
const result = await api.syncFromWoo();
if (result.ok) {
- alert(`Sincronización completada: ${result.synced} productos importados`);
+ modal.success(`Sincronización completada: ${result.synced} productos importados`);
} else {
- alert("Error: " + (result.error || "Error desconocido"));
+ modal.error("Error: " + (result.error || "Error desconocido"));
}
await this.load();
} catch (e) {
console.error("Error syncing products:", e);
- alert("Error sincronizando: " + (e.message || e));
+ modal.error("Error sincronizando: " + (e.message || e));
} finally {
btn.disabled = false;
btn.textContent = "Resincronizar";
@@ -636,7 +634,7 @@ class ProductsCrud extends HTMLElement {
setTimeout(() => { btn.textContent = "Guardar cambios"; btn.disabled = false; }, 1500);
} catch (e) {
console.error("Error saving product:", e);
- alert("Error guardando: " + (e.message || e));
+ modal.error("Error guardando: " + (e.message || e));
btn.textContent = "Guardar cambios";
btn.disabled = false;
}
@@ -710,7 +708,7 @@ class ProductsCrud extends HTMLElement {
const categoryName = select.value;
if (!categoryName) {
- alert("Seleccioná una categoría");
+ modal.warn("Seleccioná una categoría");
return;
}
@@ -753,7 +751,7 @@ class ProductsCrud extends HTMLElement {
setTimeout(() => { btn.textContent = "Agregar"; btn.disabled = false; }, 1500);
} catch (e) {
console.error("Error adding category:", e);
- alert("Error agregando categoría: " + (e.message || e));
+ modal.error("Error agregando categoría: " + (e.message || e));
btn.textContent = "Agregar";
btn.disabled = false;
}
@@ -804,7 +802,7 @@ class ProductsCrud extends HTMLElement {
}, 1500);
} catch (e) {
console.error("Error saving product unit:", e);
- alert("Error guardando: " + (e.message || e));
+ modal.error("Error guardando: " + (e.message || e));
btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar";
btn.disabled = false;
}
diff --git a/public/components/prompts-crud.js b/public/components/prompts-crud.js
index 314c7ea..e1d5dbd 100644
--- a/public/components/prompts-crud.js
+++ b/public/components/prompts-crud.js
@@ -1,5 +1,6 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
+import { modal } from "../lib/modal.js";
const PROMPT_LABELS = {
router: "Router (clasificador de dominio)",
@@ -376,61 +377,59 @@ class PromptsCrud extends HTMLElement {
const model = this.shadowRoot.getElementById("modelSelect").value;
if (!content.trim()) {
- alert("El contenido no puede estar vacio");
+ modal.warn("El contenido no puede estar vacío");
return;
}
try {
await api.savePrompt(this.selected.prompt_key, { content, model });
- alert("Prompt guardado correctamente");
+ modal.success("Prompt guardado correctamente");
await this.load();
// Re-seleccionar el prompt actual
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
if (updated) this.selectPrompt(updated);
} catch (e) {
console.error("Error saving prompt:", e);
- alert("Error guardando: " + (e.message || e));
+ modal.error("Error guardando: " + (e.message || e));
}
}
async reset() {
- if (!confirm("Esto desactivara todas las versiones custom y volvera al prompt por defecto. Continuar?")) {
- return;
- }
+ const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?");
+ if (!confirmed) return;
try {
await api.resetPrompt(this.selected.prompt_key);
- alert("Prompt reseteado a default");
+ modal.success("Prompt reseteado a default");
await this.load();
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
if (updated) this.selectPrompt(updated);
} catch (e) {
console.error("Error resetting prompt:", e);
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
}
async rollback(version) {
- if (!confirm(`Restaurar version ${version}? Se creara una nueva version con ese contenido.`)) {
- return;
- }
+ const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`);
+ if (!confirmed) return;
try {
await api.rollbackPrompt(this.selected.prompt_key, version);
- alert("Version restaurada");
+ modal.success("Versión restaurada");
await this.load();
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
if (updated) this.selectPrompt(updated);
} catch (e) {
console.error("Error rolling back:", e);
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
}
async runTest() {
const testMessage = this.shadowRoot.getElementById("testMessage").value;
if (!testMessage.trim()) {
- alert("Ingresa un mensaje de prueba");
+ modal.warn("Ingresa un mensaje de prueba");
return;
}
diff --git a/public/components/recommendations-crud.js b/public/components/recommendations-crud.js
index 632df46..157a18a 100644
--- a/public/components/recommendations-crud.js
+++ b/public/components/recommendations-crud.js
@@ -1,6 +1,7 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
import { navigateToItem } from "../lib/router.js";
+import { modal } from "../lib/modal.js";
class RecommendationsCrud extends HTMLElement {
static get observedAttributes() {
@@ -661,7 +662,7 @@ class RecommendationsCrud extends HTMLElement {
addBtn.onclick = () => {
if (!selectedProductId) {
- alert("Selecciona un producto primero");
+ modal.warn("Selecciona un producto primero");
return;
}
@@ -792,7 +793,7 @@ class RecommendationsCrud extends HTMLElement {
const active = this.shadowRoot.getElementById("activeInput").checked;
if (!ruleKey) {
- alert("El nombre de la regla es requerido");
+ modal.warn("El nombre de la regla es requerido");
return;
}
@@ -812,11 +813,11 @@ class RecommendationsCrud extends HTMLElement {
if (this.currentRuleType === "crosssell") {
if (!this.selectedTriggerProducts.length) {
- alert("Selecciona al menos un producto trigger");
+ modal.warn("Selecciona al menos un producto trigger");
return;
}
if (!this.selectedRecommendedProducts.length) {
- alert("Selecciona al menos un producto para recomendar");
+ modal.warn("Selecciona al menos un producto para recomendar");
return;
}
data.trigger_product_ids = this.selectedTriggerProducts;
@@ -827,7 +828,7 @@ class RecommendationsCrud extends HTMLElement {
data.trigger_event = triggerEvent;
if (!this.ruleItems.length) {
- alert("Agrega al menos un producto con cantidad");
+ modal.warn("Agrega al menos un producto con cantidad");
return;
}
@@ -853,13 +854,14 @@ class RecommendationsCrud extends HTMLElement {
this.renderForm();
} catch (e) {
console.error("Error saving recommendation:", e);
- alert("Error guardando: " + (e.message || e));
+ modal.error("Error guardando: " + (e.message || e));
}
}
async delete() {
if (!this.selected?.id) return;
- if (!confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`)) return;
+ const confirmed = await modal.confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`);
+ if (!confirmed) return;
try {
await api.deleteRecommendation(this.selected.id);
@@ -869,7 +871,7 @@ class RecommendationsCrud extends HTMLElement {
this.renderForm();
} catch (e) {
console.error("Error deleting recommendation:", e);
- alert("Error eliminando: " + (e.message || e));
+ modal.error("Error eliminando: " + (e.message || e));
}
}
diff --git a/public/components/takeovers-crud.js b/public/components/takeovers-crud.js
index 4490a2c..ba6a02a 100644
--- a/public/components/takeovers-crud.js
+++ b/public/components/takeovers-crud.js
@@ -1,5 +1,6 @@
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
+import { modal } from "../lib/modal.js";
class TakeoversCrud extends HTMLElement {
constructor() {
@@ -10,6 +11,7 @@ class TakeoversCrud extends HTMLElement {
this.loading = false;
this.products = [];
this.pendingCount = 0;
+ this.cartItemsToAdd = []; // Items que el humano quiere agregar al carrito
this.shadowRoot.innerHTML = `
@@ -259,9 +277,30 @@ class TakeoversCrud extends HTMLElement {
` : ""}
+
@@ -297,7 +336,148 @@ class TakeoversCrud extends HTMLElement {
aliasSection.style.display = addAliasCheck.checked ? "block" : "none";
};
+ // Limpiar items al cambiar de takeover
+ this.cartItemsToAdd = [];
+ this.renderCartItemsList();
+
this.setupProductSelector();
+ this.setupCartProductSelector();
+ }
+
+ renderCartItemsList() {
+ const container = this.shadowRoot.getElementById("cartItemsList");
+ if (!container) return;
+
+ if (this.cartItemsToAdd.length === 0) {
+ container.innerHTML = `
Sin items agregados
`;
+ return;
+ }
+
+ container.innerHTML = this.cartItemsToAdd.map((item, idx) => `
+
+ ${item.name}
+
+
+
+
+ `).join("");
+
+ // Event listeners para editar/eliminar
+ container.querySelectorAll(".qty").forEach(input => {
+ input.onchange = (e) => {
+ const idx = parseInt(e.target.dataset.idx, 10);
+ this.cartItemsToAdd[idx].qty = parseFloat(e.target.value) || 1;
+ };
+ });
+
+ container.querySelectorAll(".unit-select").forEach(select => {
+ select.onchange = (e) => {
+ const idx = parseInt(e.target.dataset.idx, 10);
+ this.cartItemsToAdd[idx].unit = e.target.value;
+ };
+ });
+
+ container.querySelectorAll(".remove-btn").forEach(btn => {
+ btn.onclick = (e) => {
+ const idx = parseInt(e.target.dataset.idx, 10);
+ this.cartItemsToAdd.splice(idx, 1);
+ this.renderCartItemsList();
+ };
+ });
+ }
+
+ setupCartProductSelector() {
+ const searchInput = this.shadowRoot.getElementById("cartProductSearch");
+ const dropdown = this.shadowRoot.getElementById("cartProductDropdown");
+ const addBtn = this.shadowRoot.getElementById("addCartItemBtn");
+ const qtyInput = this.shadowRoot.getElementById("cartQtyInput");
+ const unitSelect = this.shadowRoot.getElementById("cartUnitSelect");
+
+ if (!searchInput || !dropdown || !addBtn) return;
+
+ let selectedProduct = null;
+
+ const renderDropdown = (query) => {
+ const q = (query || "").toLowerCase().trim();
+ let filtered = this.products;
+ if (q) {
+ filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
+ }
+ filtered = filtered.slice(0, 30);
+
+ if (!filtered.length) {
+ dropdown.classList.remove("open");
+ return;
+ }
+
+ dropdown.innerHTML = filtered.map(p => `
+
+ ${p.name}
+ $${p.price || 0}
+
+ `).join("");
+
+ dropdown.querySelectorAll(".product-option").forEach(opt => {
+ opt.onclick = () => {
+ selectedProduct = {
+ woo_id: parseInt(opt.dataset.id, 10),
+ name: opt.dataset.name,
+ };
+ searchInput.value = selectedProduct.name;
+ // Auto-seleccionar unidad según producto
+ const productUnit = opt.dataset.unit || "kg";
+ if (unitSelect) {
+ unitSelect.value = productUnit === "unit" || productUnit === "Unidad" ? "unit" : "kg";
+ }
+ dropdown.classList.remove("open");
+ };
+ });
+
+ dropdown.classList.add("open");
+ };
+
+ searchInput.oninput = () => {
+ selectedProduct = null;
+ clearTimeout(this._cartSearchTimer);
+ this._cartSearchTimer = setTimeout(() => renderDropdown(searchInput.value), 150);
+ };
+
+ searchInput.onfocus = () => renderDropdown(searchInput.value);
+
+ addBtn.onclick = () => {
+ if (!selectedProduct) {
+ modal.warn("Selecciona un producto primero");
+ return;
+ }
+
+ const qty = parseFloat(qtyInput.value) || 1;
+ const unit = unitSelect.value || "kg";
+
+ this.cartItemsToAdd.push({
+ woo_id: selectedProduct.woo_id,
+ name: selectedProduct.name,
+ qty,
+ unit,
+ });
+
+ // Limpiar inputs
+ searchInput.value = "";
+ qtyInput.value = "1";
+ selectedProduct = null;
+
+ this.renderCartItemsList();
+ };
+
+ // Close dropdown on outside click
+ document.addEventListener("click", (e) => {
+ if (!this.shadowRoot.getElementById("cartProductSelector")?.contains(e.target)) {
+ dropdown.classList.remove("open");
+ }
+ });
}
escapeHtml(str) {
@@ -362,7 +542,7 @@ class TakeoversCrud extends HTMLElement {
async respond() {
const response = this.shadowRoot.getElementById("responseInput").value.trim();
if (!response) {
- alert("Escribe una respuesta");
+ modal.warn("Escribe una respuesta");
return;
}
@@ -376,32 +556,42 @@ class TakeoversCrud extends HTMLElement {
};
}
+ // Items a agregar al carrito del cliente
+ const cartItems = this.cartItemsToAdd.length > 0 ? this.cartItemsToAdd : null;
+
try {
- await api.respondTakeover(this.selected.id, { response, add_alias: addAlias });
- alert("Respuesta enviada");
+ await api.respondTakeover(this.selected.id, {
+ response,
+ add_alias: addAlias,
+ cart_items: cartItems,
+ });
+
+ const itemsMsg = cartItems ? ` (${cartItems.length} item(s) agregados al carrito)` : "";
+ modal.success("Respuesta enviada" + itemsMsg);
+
this.selected = null;
+ this.cartItemsToAdd = [];
await this.load();
this.renderForm();
} catch (e) {
console.error("Error responding:", e);
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
}
async cancel() {
- if (!confirm("Cancelar este takeover? El cliente no recibira respuesta automatica.")) {
- return;
- }
+ const confirmed = await modal.confirm("¿Cancelar este takeover? El cliente no recibirá respuesta automática.");
+ if (!confirmed) return;
try {
await api.cancelTakeover(this.selected.id);
- alert("Takeover cancelado");
+ modal.success("Takeover cancelado");
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error cancelling:", e);
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
}
}
diff --git a/public/components/test-panel.js b/public/components/test-panel.js
index baa77ac..93de0fd 100644
--- a/public/components/test-panel.js
+++ b/public/components/test-panel.js
@@ -1,4 +1,5 @@
import { api } from "../lib/api.js";
+import { modal } from "../lib/modal.js";
// Datos aleatorios para generar usuarios de prueba
const NOMBRES = ["Juan", "María", "Carlos", "Ana", "Pedro", "Laura", "Diego", "Sofía"];
@@ -300,7 +301,7 @@ class TestPanel extends HTMLElement {
}
if (this.products.length === 0) {
- alert("No hay productos con stock disponible");
+ modal.warn("No hay productos con stock disponible");
return;
}
@@ -420,11 +421,11 @@ class TestPanel extends HTMLElement {
this.shadowRoot.getElementById("orderResult").style.display = "block";
this.shadowRoot.getElementById("inputAmount").value = result.total || "";
} else {
- alert("Error: " + (result.error || "Error desconocido"));
+ modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createOrder error:", e);
- alert("Error creando orden: " + e.message);
+ modal.error("Error creando orden: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Crear Orden en WooCommerce";
@@ -435,13 +436,13 @@ class TestPanel extends HTMLElement {
async createPaymentLink() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
- alert("Primero creá una orden");
+ modal.warn("Primero creá una orden");
return;
}
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value);
if (!amount || amount <= 0) {
- alert("Ingresá un monto válido");
+ modal.warn("Ingresá un monto válido");
return;
}
@@ -464,11 +465,11 @@ class TestPanel extends HTMLElement {
this.shadowRoot.getElementById("preferenceIdValue").textContent = result.preference_id || "—";
this.shadowRoot.getElementById("paymentResult").style.display = "block";
} else {
- alert("Error: " + (result.error || "Error desconocido"));
+ modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createPaymentLink error:", e);
- alert("Error generando link: " + e.message);
+ modal.error("Error generando link: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Generar Link de Pago";
@@ -479,7 +480,7 @@ class TestPanel extends HTMLElement {
async simulateWebhook() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
- alert("Primero creá una orden");
+ modal.warn("Primero creá una orden");
return;
}
@@ -501,11 +502,11 @@ class TestPanel extends HTMLElement {
this.shadowRoot.getElementById("webhookOrderStatusValue").textContent = result.order_status || "processing";
this.shadowRoot.getElementById("webhookResult").style.display = "block";
} else {
- alert("Error: " + (result.error || "Error desconocido"));
+ modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] simulateWebhook error:", e);
- alert("Error simulando webhook: " + e.message);
+ modal.error("Error simulando webhook: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Simular Pago Exitoso";
diff --git a/public/components/users-crud.js b/public/components/users-crud.js
index d80c2e6..16d7910 100644
--- a/public/components/users-crud.js
+++ b/public/components/users-crud.js
@@ -1,6 +1,7 @@
import { api } from "../lib/api.js";
import { emit, on } from "../lib/bus.js";
import { navigateToItem } from "../lib/router.js";
+import { modal } from "../lib/modal.js";
class UsersCrud extends HTMLElement {
constructor() {
@@ -291,24 +292,26 @@ class UsersCrud extends HTMLElement {
};
this.shadowRoot.getElementById("deleteConv").onclick = async () => {
- if (!confirm(`¿Eliminar la conversacion de "${u.chat_id}"?`)) return;
+ const confirmed = await modal.confirm(`¿Eliminar la conversación de "${u.chat_id}"?`);
+ if (!confirmed) return;
try {
await api.deleteConversation(u.chat_id);
- alert("Conversacion eliminada");
+ modal.success("Conversación eliminada");
} catch (e) {
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
};
this.shadowRoot.getElementById("deleteUser").onclick = async () => {
- if (!confirm(`¿Eliminar usuario "${u.chat_id}", su conversacion y el customer en Woo?`)) return;
+ const confirmed = await modal.confirm(`¿Eliminar usuario "${u.chat_id}", su conversación y el customer en Woo?`);
+ if (!confirmed) return;
try {
await api.deleteUser(u.chat_id, { deleteWoo: true });
this.selected = null;
await this.load();
this.renderDetail();
} catch (e) {
- alert("Error: " + (e.message || e));
+ modal.error("Error: " + (e.message || e));
}
};
}
diff --git a/public/lib/modal.js b/public/lib/modal.js
new file mode 100644
index 0000000..3c126a2
--- /dev/null
+++ b/public/lib/modal.js
@@ -0,0 +1,232 @@
+/**
+ * Sistema de modales centralizado para reemplazar alert() nativos
+ * Uso:
+ * import { modal } from './lib/modal.js';
+ * modal.success("Guardado correctamente");
+ * modal.error("Error: " + e.message);
+ * modal.info("Información importante");
+ * modal.warn("Advertencia");
+ * const ok = await modal.confirm("¿Estás seguro?");
+ */
+
+const STYLES = `
+ .modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+ animation: fadeIn 0.15s ease-out;
+ }
+
+ @keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+
+ @keyframes slideIn {
+ from { transform: translateY(-20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+ }
+
+ .modal-box {
+ background: #1e1e1e;
+ border-radius: 8px;
+ padding: 24px;
+ min-width: 320px;
+ max-width: 480px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ animation: slideIn 0.2s ease-out;
+ border: 1px solid #333;
+ }
+
+ .modal-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ .modal-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+ }
+
+ .modal-icon.success { background: #22c55e20; color: #22c55e; }
+ .modal-icon.error { background: #ef444420; color: #ef4444; }
+ .modal-icon.warn { background: #f59e0b20; color: #f59e0b; }
+ .modal-icon.info { background: #3b82f620; color: #3b82f6; }
+ .modal-icon.confirm { background: #8b5cf620; color: #8b5cf6; }
+
+ .modal-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+ }
+
+ .modal-message {
+ color: #ccc;
+ font-size: 14px;
+ line-height: 1.5;
+ margin-bottom: 20px;
+ word-break: break-word;
+ }
+
+ .modal-buttons {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ }
+
+ .modal-btn {
+ padding: 8px 16px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ border: none;
+ transition: all 0.15s;
+ }
+
+ .modal-btn:hover {
+ filter: brightness(1.1);
+ }
+
+ .modal-btn.primary {
+ background: #3b82f6;
+ color: white;
+ }
+
+ .modal-btn.secondary {
+ background: #333;
+ color: #ccc;
+ border: 1px solid #444;
+ }
+
+ .modal-btn.danger {
+ background: #ef4444;
+ color: white;
+ }
+`;
+
+// Inyectar estilos una sola vez
+let stylesInjected = false;
+function injectStyles() {
+ if (stylesInjected) return;
+ const style = document.createElement("style");
+ style.textContent = STYLES;
+ document.head.appendChild(style);
+ stylesInjected = true;
+}
+
+const ICONS = {
+ success: "✓",
+ error: "✕",
+ warn: "!",
+ info: "i",
+ confirm: "?",
+};
+
+const TITLES = {
+ success: "Éxito",
+ error: "Error",
+ warn: "Advertencia",
+ info: "Información",
+ confirm: "Confirmar",
+};
+
+function createModal({ type, message, showCancel = false, confirmText = "Aceptar", cancelText = "Cancelar" }) {
+ injectStyles();
+
+ return new Promise((resolve) => {
+ const overlay = document.createElement("div");
+ overlay.className = "modal-overlay";
+
+ const box = document.createElement("div");
+ box.className = "modal-box";
+
+ box.innerHTML = `
+
+
${escapeHtml(message)}
+
+ ${showCancel ? `` : ""}
+
+
+ `;
+
+ overlay.appendChild(box);
+ document.body.appendChild(overlay);
+
+ // Focus en el botón principal
+ const confirmBtn = box.querySelector('[data-action="confirm"]');
+ confirmBtn?.focus();
+
+ const close = (result) => {
+ overlay.style.animation = "fadeIn 0.15s ease-out reverse";
+ setTimeout(() => {
+ overlay.remove();
+ resolve(result);
+ }, 140);
+ };
+
+ // Click en botones
+ box.addEventListener("click", (e) => {
+ const action = e.target.dataset?.action;
+ if (action === "confirm") close(true);
+ if (action === "cancel") close(false);
+ });
+
+ // Click fuera cierra (solo para mensajes, no confirms)
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay && !showCancel) {
+ close(true);
+ }
+ });
+
+ // Escape cierra
+ const handleKeydown = (e) => {
+ if (e.key === "Escape") {
+ close(showCancel ? false : true);
+ document.removeEventListener("keydown", handleKeydown);
+ }
+ if (e.key === "Enter") {
+ close(true);
+ document.removeEventListener("keydown", handleKeydown);
+ }
+ };
+ document.addEventListener("keydown", handleKeydown);
+ });
+}
+
+function escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+export const modal = {
+ success: (message) => createModal({ type: "success", message }),
+ error: (message) => createModal({ type: "error", message }),
+ warn: (message) => createModal({ type: "warn", message }),
+ info: (message) => createModal({ type: "info", message }),
+ confirm: (message, { confirmText = "Confirmar", cancelText = "Cancelar" } = {}) =>
+ createModal({ type: "confirm", message, showCancel: true, confirmText, cancelText }),
+};
+
+// También exportar como default para conveniencia
+export default modal;
diff --git a/src/modules/0-ui/controllers/takeovers.js b/src/modules/0-ui/controllers/takeovers.js
index b22dd9d..a39f8eb 100644
--- a/src/modules/0-ui/controllers/takeovers.js
+++ b/src/modules/0-ui/controllers/takeovers.js
@@ -63,7 +63,7 @@ export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => {
try {
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
const id = parseInt(req.params.id, 10);
- const { response, responded_by, add_alias } = req.body || {};
+ const { response, responded_by, add_alias, cart_items } = req.body || {};
if (!response) {
return res.status(400).json({ ok: false, error: "response_required" });
@@ -75,6 +75,7 @@ export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => {
response,
respondedBy: responded_by || null,
addAlias: add_alias || null,
+ cartItems: cart_items || null,
});
res.json(result);
} catch (err) {
diff --git a/src/modules/0-ui/handlers/takeovers.js b/src/modules/0-ui/handlers/takeovers.js
index ada9ed8..19439c6 100644
--- a/src/modules/0-ui/handlers/takeovers.js
+++ b/src/modules/0-ui/handlers/takeovers.js
@@ -10,7 +10,7 @@ import {
getTakeoverStats,
} from "../db/takeoverRepo.js";
import { insertAlias, upsertAliasMapping } from "../db/repo.js";
-import { getRecentMessagesForLLM } from "../../2-identity/db/repo.js";
+import { getRecentMessagesForLLM, getConversationState, upsertConversationState } from "../../2-identity/db/repo.js";
/**
* Lista takeovers pendientes de respuesta
@@ -99,19 +99,72 @@ export async function handleRespondToTakeover({
response,
respondedBy = null,
addAlias = null, // { query: string, woo_product_id: number }
+ cartItems = null, // [{ woo_id, name, qty, unit }] - items a agregar al carrito
}) {
- // Responder al takeover
+ // Responder al takeover con los items del carrito
const result = await respondToTakeover({
tenantId,
id,
humanResponse: response,
respondedBy,
+ cartItems, // Pasar los items para que se guarden
});
if (!result) {
throw new Error("Takeover not found or already responded");
}
+ // Si hay items para agregar al carrito, actualizar el estado de la conversación
+ if (cartItems && cartItems.length > 0 && result.chat_id) {
+ try {
+ // Obtener estado actual
+ const currentState = await getConversationState(tenantId, result.chat_id);
+ const context = currentState?.context || {};
+ const order = context.order || { cart: [], pending: [] };
+
+ // Agregar los items al carrito
+ const newCartItems = cartItems.map(item => ({
+ woo_id: item.woo_id,
+ qty: parseFloat(item.qty) || 1,
+ unit: item.unit || "kg",
+ name: item.name || null,
+ price: item.price || null,
+ }));
+
+ order.cart = [...(order.cart || []), ...newCartItems];
+
+ // Actualizar estado: cambiar a CART para que el bot retome
+ await upsertConversationState({
+ tenant_id: tenantId,
+ wa_chat_id: result.chat_id,
+ state: "CART", // Retomar flujo normal
+ last_intent: "human_response",
+ context: { ...context, order },
+ });
+
+ console.log(`[takeovers] Added ${newCartItems.length} items to cart for ${result.chat_id}`);
+ } catch (e) {
+ console.error("[takeovers] Error updating cart:", e);
+ // No fallar si hay error al actualizar carrito
+ }
+ } else if (result.chat_id) {
+ // Si no hay items pero respondió, igual cambiar estado a CART preservando el context
+ try {
+ const currentState = await getConversationState(tenantId, result.chat_id);
+ const context = currentState?.context || {};
+
+ await upsertConversationState({
+ tenant_id: tenantId,
+ wa_chat_id: result.chat_id,
+ state: "CART",
+ last_intent: "human_response",
+ context,
+ });
+ } catch (e) {
+ console.error("[takeovers] Error updating state:", e);
+ }
+ }
+
// Si se pidió agregar alias, hacerlo
if (addAlias && addAlias.query && addAlias.woo_product_id) {
try {
@@ -138,6 +191,7 @@ export async function handleRespondToTakeover({
return {
ok: true,
takeover: result,
+ cart_items_added: cartItems?.length || 0,
message: "Response sent successfully",
};
}
diff --git a/src/modules/3-turn-engine/orderModel.js b/src/modules/3-turn-engine/orderModel.js
index 3ca04bd..db96d3d 100644
--- a/src/modules/3-turn-engine/orderModel.js
+++ b/src/modules/3-turn-engine/orderModel.js
@@ -53,7 +53,9 @@ export function createPendingItem({
selected_unit = null,
qty = null,
unit = null,
- status = PendingStatus.NEEDS_TYPE
+ status = PendingStatus.NEEDS_TYPE,
+ requested_qty = null,
+ requested_unit = null,
}) {
return {
id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
@@ -66,6 +68,8 @@ export function createPendingItem({
qty, // Cantidad (null si NEEDS_QUANTITY)
unit, // Unidad elegida por usuario
status,
+ requested_qty, // Cantidad pedida originalmente por el usuario (para usar después de selección)
+ requested_unit, // Unidad pedida originalmente por el usuario
};
}
diff --git a/src/modules/3-turn-engine/stateHandlers.js b/src/modules/3-turn-engine/stateHandlers.js
index 9513e34..52e54a8 100644
--- a/src/modules/3-turn-engine/stateHandlers.js
+++ b/src/modules/3-turn-engine/stateHandlers.js
@@ -20,6 +20,7 @@ import {
} from "./orderModel.js";
import { handleRecommend } from "./recommendations.js";
import { getProductQtyRules } from "../0-ui/db/repo.js";
+import { createHumanTakeoverResponse } from "./nlu/humanFallback.js";
// ─────────────────────────────────────────────────────────────
// Utilidades
@@ -134,9 +135,55 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI
let currentOrder = order || createEmptyOrder();
const actions = [];
- // 1) Si hay pending items sin resolver, procesar clarificación
+ // Intents que tienen prioridad sobre pending items (permiten "escapar" del loop)
+ const priorityIntents = ["view_cart", "confirm_order", "greeting"];
+ const isPriorityIntent = priorityIntents.includes(intent);
+
+ // Detectar si el usuario quiere cancelar/saltar el pending item actual
const pendingItem = getNextPendingItem(currentOrder);
- if (pendingItem) {
+ const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i;
+ const wantsToSkipPending = pendingItem && cancelPhrases.test(text || "");
+
+ // Si quiere saltar el pending, eliminarlo
+ if (wantsToSkipPending && pendingItem) {
+ currentOrder = {
+ ...currentOrder,
+ pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id),
+ };
+ audit.skipped_pending = pendingItem.query;
+
+ // Si hay más pending items, continuar con el siguiente
+ const nextPending = getNextPendingItem(currentOrder);
+ if (nextPending) {
+ const { question } = formatOptionsForDisplay(nextPending);
+ return {
+ plan: {
+ reply: `Ok, salteo "${pendingItem.query}". ${question}`,
+ next_state: ConversationState.CART,
+ intent: "add_to_cart",
+ missing_fields: ["product_selection"],
+ order_action: "none",
+ },
+ decision: { actions: [], order: currentOrder, audit },
+ };
+ }
+
+ // No hay más pending, mostrar confirmación
+ const cartDisplay = formatCartForDisplay(currentOrder);
+ return {
+ plan: {
+ reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`,
+ next_state: ConversationState.CART,
+ intent: "other",
+ missing_fields: [],
+ order_action: "none",
+ },
+ decision: { actions: [], order: currentOrder, audit },
+ };
+ }
+
+ // 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación
+ if (pendingItem && !isPriorityIntent) {
const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit });
if (result) return result;
}
@@ -768,6 +815,8 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
}
// Multiple candidates, needs selection
+ // Guardar la cantidad y unidad que pidió el usuario para usarla después de seleccionar
+ const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0;
return createPendingItem({
query,
candidates: cands.slice(0, 20).map(c => ({
@@ -778,6 +827,9 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) {
display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
})),
status: PendingStatus.NEEDS_TYPE,
+ // Guardar cantidad/unidad pedida para usarla al seleccionar
+ requested_qty: hasQty ? Number(quantity) : null,
+ requested_unit: normalizeUnit(unit) || null,
});
}
@@ -806,7 +858,19 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) {
const selected = pendingItem.candidates[idx - 1];
const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] });
- const needsQuantity = displayUnit !== "unit";
+
+ // Usar la cantidad que pidió originalmente el usuario, o la unidad pedida para determinar si necesita cantidad
+ const requestedQty = pendingItem.requested_qty;
+ const requestedUnit = pendingItem.requested_unit || displayUnit;
+ const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0;
+
+ // Si vende por peso y no tenemos cantidad, preguntar. Si vende por unidad y tiene cantidad, usar esa.
+ const sellsByWeight = displayUnit !== "unit";
+ const needsQuantity = sellsByWeight && !hasRequestedQty;
+
+ // Determinar la cantidad final
+ const finalQty = hasRequestedQty ? requestedQty : 1;
+ const finalUnit = requestedUnit || displayUnit;
const updatedOrder = updatePendingItem(order, pendingItem.id, {
selected_woo_id: selected.woo_id,
@@ -815,8 +879,8 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
selected_unit: displayUnit,
candidates: [],
status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
- qty: needsQuantity ? null : 1,
- unit: displayUnit,
+ qty: needsQuantity ? null : finalQty,
+ unit: finalUnit,
});
// Si necesita cantidad, preguntar
@@ -836,9 +900,13 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
// Listo, mover al cart
const finalOrder = moveReadyToCart(updatedOrder);
+ // Formatear la cantidad según la unidad
+ const qtyDisplay = displayUnit === "unit"
+ ? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}`
+ : `${finalQty}${displayUnit}`;
return {
plan: {
- reply: `Perfecto, anoto 1 ${selected.name}. ¿Algo más?`,
+ reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}. ¿Algo más?`,
next_state: ConversationState.CART,
intent: "add_to_cart",
missing_fields: [],
@@ -848,6 +916,69 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending
};
}
+ // Si no hay candidatos (producto no encontrado) y el usuario da texto libre,
+ // intentar re-buscar con el texto como aclaración
+ if ((!pendingItem.candidates || pendingItem.candidates.length === 0) && text && text.length > 2) {
+ const newQuery = text.trim();
+ const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 });
+ const newCandidates = searchResult?.candidates || [];
+ audit.retry_search = { query: newQuery, count: newCandidates.length };
+
+ if (newCandidates.length > 0) {
+ // Encontramos opciones con la nueva búsqueda
+ const updatedPending = {
+ ...pendingItem,
+ query: newQuery,
+ candidates: newCandidates.slice(0, 20).map(c => ({
+ woo_id: c.woo_product_id,
+ name: c.name,
+ price: c.price,
+ sell_unit: c.sell_unit || null,
+ display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }),
+ })),
+ };
+
+ const updatedOrder = {
+ ...order,
+ pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p),
+ };
+
+ const { question } = formatOptionsForDisplay(updatedPending);
+ return {
+ plan: {
+ reply: question,
+ next_state: ConversationState.CART,
+ intent: "add_to_cart",
+ missing_fields: ["product_selection"],
+ order_action: "none",
+ },
+ decision: { actions: [], order: updatedOrder, audit },
+ };
+ }
+
+ // Sigue sin encontrar después de la aclaración -> escalar a humano
+ // Primero eliminar el pending item que no se puede resolver
+ const orderWithoutPending = {
+ ...order,
+ pending: (order.pending || []).filter(p => p.id !== pendingItem.id),
+ };
+
+ audit.escalated_to_human = true;
+ audit.original_query = pendingItem.query;
+ audit.retry_query = newQuery;
+
+ // Crear takeover con contexto de la búsqueda fallida
+ return createHumanTakeoverResponse({
+ pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`,
+ order: orderWithoutPending,
+ context: {
+ original_query: pendingItem.query,
+ user_clarification: newQuery,
+ search_attempts: 2,
+ },
+ });
+ }
+
// No entendió, volver a preguntar
const { question } = formatOptionsForDisplay(pendingItem);
return {