modificando el patron del sistema, orientado mas al usuario
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<style>
|
||||
@@ -79,6 +81,22 @@ class TakeoversCrud extends HTMLElement {
|
||||
.product-option:hover { background:#1a2535; }
|
||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||
|
||||
.cart-section { background:#0d2818; border:1px solid #2ecc71; border-radius:8px; padding:12px; margin-bottom:12px; }
|
||||
.cart-section h4 { margin:0 0 12px; font-size:13px; color:#2ecc71; display:flex; align-items:center; gap:8px; }
|
||||
.cart-section h4 svg { width:16px; height:16px; fill:#2ecc71; }
|
||||
.cart-items-list { margin-bottom:12px; }
|
||||
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:#0f1520; border-radius:6px; margin-bottom:6px; }
|
||||
.cart-item-row .name { flex:1; font-size:13px; color:#e7eef7; }
|
||||
.cart-item-row .qty { width:60px; text-align:center; }
|
||||
.cart-item-row .unit-select { width:80px; }
|
||||
.cart-item-row .remove-btn { background:#e74c3c; color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
|
||||
.cart-item-row .remove-btn:hover { background:#c0392b; }
|
||||
.add-cart-row { display:flex; gap:8px; align-items:flex-end; }
|
||||
.add-cart-row .product-selector { flex:1; }
|
||||
.add-cart-row .qty-input { width:70px; }
|
||||
.add-cart-row .unit-select { width:80px; }
|
||||
.add-cart-row button { white-space:nowrap; }
|
||||
|
||||
.no-pending { text-align:center; padding:60px 20px; color:#2ecc71; }
|
||||
.no-pending svg { width:48px; height:48px; fill:#2ecc71; margin-bottom:16px; }
|
||||
</style>
|
||||
@@ -259,9 +277,30 @@ class TakeoversCrud extends HTMLElement {
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="cart-section">
|
||||
<h4>
|
||||
<svg viewBox="0 0 24 24"><path d="M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zM1 2v2h2l3.6 7.59-1.35 2.45c-.16.28-.25.61-.25.96 0 1.1.9 2 2 2h12v-2H7.42c-.14 0-.25-.11-.25-.25l.03-.12.9-1.63h7.45c.75 0 1.41-.41 1.75-1.03l3.58-6.49c.08-.14.12-.31.12-.49 0-.55-.45-1-1-1H5.21l-.94-2H1zm16 16c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
||||
Agregar al carrito del cliente
|
||||
</h4>
|
||||
<div class="cart-items-list" id="cartItemsList"></div>
|
||||
<div class="add-cart-row">
|
||||
<div class="product-selector" id="cartProductSelector">
|
||||
<input type="text" id="cartProductSearch" placeholder="Buscar producto..." />
|
||||
<div class="product-dropdown" id="cartProductDropdown"></div>
|
||||
</div>
|
||||
<input type="number" id="cartQtyInput" class="qty-input" placeholder="Cant" min="0.1" step="0.1" value="1" />
|
||||
<select id="cartUnitSelect" class="unit-select">
|
||||
<option value="kg">kg</option>
|
||||
<option value="unit">unidad</option>
|
||||
<option value="g">gramos</option>
|
||||
</select>
|
||||
<button type="button" id="addCartItemBtn" class="secondary">+ Agregar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Tu respuesta (se enviara como el bot)</label>
|
||||
<textarea id="responseInput" placeholder="Ej: Disculpa, no tenemos ese producto. Pero tenemos..."></textarea>
|
||||
<textarea id="responseInput" placeholder="Ej: Te anoto 2kg de vacío. ¿Algo más?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="alias-section">
|
||||
@@ -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 = `<div style="font-size:12px;color:#8aa0b5;padding:8px;">Sin items agregados</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = this.cartItemsToAdd.map((item, idx) => `
|
||||
<div class="cart-item-row">
|
||||
<span class="name">${item.name}</span>
|
||||
<input type="number" class="qty" value="${item.qty}" min="0.1" step="0.1" data-idx="${idx}" />
|
||||
<select class="unit-select" data-idx="${idx}">
|
||||
<option value="kg" ${item.unit === 'kg' ? 'selected' : ''}>kg</option>
|
||||
<option value="unit" ${item.unit === 'unit' ? 'selected' : ''}>unidad</option>
|
||||
<option value="g" ${item.unit === 'g' ? 'selected' : ''}>gramos</option>
|
||||
</select>
|
||||
<button class="remove-btn" data-idx="${idx}">✕</button>
|
||||
</div>
|
||||
`).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 => `
|
||||
<div class="product-option" data-id="${p.woo_product_id}" data-name="${this.escapeHtml(p.name)}" data-unit="${p.sell_unit || 'kg'}">
|
||||
<span>${p.name}</span>
|
||||
<span class="price">$${p.price || 0}</span>
|
||||
</div>
|
||||
`).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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
232
public/lib/modal.js
Normal file
232
public/lib/modal.js
Normal file
@@ -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 = `
|
||||
<div class="modal-header">
|
||||
<div class="modal-icon ${type}">${ICONS[type]}</div>
|
||||
<h3 class="modal-title">${TITLES[type]}</h3>
|
||||
</div>
|
||||
<div class="modal-message">${escapeHtml(message)}</div>
|
||||
<div class="modal-buttons">
|
||||
${showCancel ? `<button class="modal-btn secondary" data-action="cancel">${cancelText}</button>` : ""}
|
||||
<button class="modal-btn ${type === "error" ? "danger" : "primary"}" data-action="confirm">${confirmText}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user