mejoras en el modelo de clarificacion de productos
This commit is contained in:
@@ -102,7 +102,7 @@ class AliasesCrud extends HTMLElement {
|
||||
|
||||
async loadProducts() {
|
||||
try {
|
||||
const data = await api.products({ limit: 500 });
|
||||
const data = await api.products({ limit: 2000 });
|
||||
this.products = data.items || [];
|
||||
} catch (e) {
|
||||
console.error("Error loading products:", e);
|
||||
|
||||
@@ -133,7 +133,7 @@ class ChatSimulator extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic: que aparezca en la columna izquierda al instante
|
||||
// 1. Actualizar lista de conversaciones
|
||||
emit("conversation:upsert", {
|
||||
chat_id: from,
|
||||
from: pushName || "test_lucas",
|
||||
@@ -143,9 +143,11 @@ class ChatSimulator extends HTMLElement {
|
||||
last_activity: new Date().toISOString(),
|
||||
last_run_id: null,
|
||||
});
|
||||
|
||||
// 2. Seleccionar el chat (si es el mismo, no recarga - optimizado en run-timeline)
|
||||
emit("ui:selectedChat", { chat_id: from });
|
||||
|
||||
// Optimistic: mostrar burbuja del usuario inmediatamente
|
||||
// 3. Mostrar burbuja optimista INMEDIATAMENTE
|
||||
emit("message:optimistic", {
|
||||
chat_id: from,
|
||||
message_id: `optimistic-${Date.now()}`,
|
||||
|
||||
@@ -61,6 +61,8 @@ class ConversationInspector extends HTMLElement {
|
||||
this.shadowRoot.getElementById("step").onclick = () => this.step();
|
||||
|
||||
this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => {
|
||||
// Si es el mismo chat, no recargar (para no borrar items optimistas)
|
||||
if (this.chatId === chat_id) return;
|
||||
this.chatId = chat_id;
|
||||
await this.loadData();
|
||||
});
|
||||
@@ -87,6 +89,17 @@ class ConversationInspector extends HTMLElement {
|
||||
const messageId = message?.message_id || null;
|
||||
if (messageId) this.highlight(messageId);
|
||||
});
|
||||
|
||||
// Listen for optimistic messages to add placeholder item
|
||||
this._unsubOptimistic = on("message:optimistic", (msg) => {
|
||||
if (!this.chatId) {
|
||||
this.chatId = msg.chat_id;
|
||||
this.shadowRoot.getElementById("chat").textContent = msg.chat_id;
|
||||
this.shadowRoot.getElementById("meta").textContent = "Nueva conversación";
|
||||
}
|
||||
if (msg.chat_id !== this.chatId) return;
|
||||
this.addOptimisticItem(msg);
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -95,6 +108,7 @@ class ConversationInspector extends HTMLElement {
|
||||
this._unsubLayout?.();
|
||||
this._unsubScroll?.();
|
||||
this._unsubSelectMessage?.();
|
||||
this._unsubOptimistic?.();
|
||||
this.pause();
|
||||
}
|
||||
|
||||
@@ -180,6 +194,14 @@ class ConversationInspector extends HTMLElement {
|
||||
metaEl.textContent = `Inspector de ${this.messages.length} mensajes.`;
|
||||
countEl.textContent = this.messages.length ? `${this.messages.length} filas` : "";
|
||||
|
||||
// Preserve optimistic items before clearing
|
||||
const optimisticItems = [...list.querySelectorAll('.item[data-message-id^="optimistic-"]')];
|
||||
|
||||
// Obtener timestamps de mensajes IN del servidor para comparar
|
||||
const serverInTimestamps = this.messages
|
||||
.filter(m => m.direction === "in")
|
||||
.map(m => new Date(m.ts).getTime());
|
||||
|
||||
list.innerHTML = "";
|
||||
this.rowMap.clear();
|
||||
this.rowOrder = [];
|
||||
@@ -196,7 +218,7 @@ class ConversationInspector extends HTMLElement {
|
||||
const intent = run?.llm_output?.intent || "—";
|
||||
const nextState = run?.llm_output?.next_state || "—";
|
||||
const prevState = row.nextRun?.prev_state || "—";
|
||||
const basket = run?.llm_output?.basket_resolved?.items || [];
|
||||
const basket = run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
|
||||
const tools = this.toolSummary(run?.tools || []);
|
||||
|
||||
const llmMeta = run?.llm_output?._llm || null;
|
||||
@@ -237,6 +259,23 @@ class ConversationInspector extends HTMLElement {
|
||||
this.rowMap.set(msg.message_id, el);
|
||||
this.rowOrder.push(msg.message_id);
|
||||
}
|
||||
|
||||
// Re-add preserved optimistic items ONLY if no server message covers it
|
||||
for (const optItem of optimisticItems) {
|
||||
// Obtener timestamp del optimista (está en el ID: optimistic-{timestamp})
|
||||
const msgId = optItem.dataset.messageId;
|
||||
const optTs = parseInt(msgId.replace("optimistic-", ""), 10) || 0;
|
||||
|
||||
// Si hay un mensaje del servidor con timestamp cercano (10 seg), no re-agregar
|
||||
const hasServerMatch = serverInTimestamps.some(ts => Math.abs(ts - optTs) < 10000);
|
||||
if (hasServerMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
list.appendChild(optItem);
|
||||
this.rowMap.set(msgId, optItem);
|
||||
this.rowOrder.push(msgId);
|
||||
}
|
||||
}
|
||||
|
||||
applyHeights() {
|
||||
@@ -302,6 +341,44 @@ class ConversationInspector extends HTMLElement {
|
||||
this.highlight(messageId);
|
||||
this._playIdx += 1;
|
||||
}
|
||||
|
||||
addOptimisticItem(msg) {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
if (!list) return;
|
||||
|
||||
// Remove any existing optimistic item
|
||||
const existing = list.querySelector(`.item[data-message-id^="optimistic-"]`);
|
||||
if (existing) existing.remove();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = "item in";
|
||||
el.dataset.messageId = msg.message_id;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="kv">
|
||||
<div class="k">IN</div>
|
||||
<div class="v">${new Date(msg.ts).toLocaleString()}</div>
|
||||
<div class="k">STATE</div>
|
||||
<div class="v">—</div>
|
||||
<div class="k">INTENT</div>
|
||||
<div class="v">—</div>
|
||||
<div class="k">NLU</div>
|
||||
<div class="v">procesando...</div>
|
||||
</div>
|
||||
<div class="cart"><strong>Carrito:</strong> —</div>
|
||||
<div class="chips"></div>
|
||||
`;
|
||||
|
||||
list.appendChild(el);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
|
||||
this.rowMap.set(msg.message_id, el);
|
||||
this.rowOrder.push(msg.message_id);
|
||||
|
||||
// Apply min height
|
||||
el.style.minHeight = "120px";
|
||||
el.style.marginBottom = "12px";
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("conversation-inspector", ConversationInspector);
|
||||
|
||||
@@ -5,7 +5,8 @@ class ProductsCrud extends HTMLElement {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.items = [];
|
||||
this.selected = null;
|
||||
this.selectedItems = []; // Array de productos seleccionados
|
||||
this.lastClickedIndex = -1; // Para Shift+Click
|
||||
this.loading = false;
|
||||
this.searchQuery = "";
|
||||
this.stockFilter = false;
|
||||
@@ -29,9 +30,10 @@ class ProductsCrud extends HTMLElement {
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
|
||||
.list { flex:1; overflow-y:auto; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item.selected { border-color:#2ecc71; background:#0f2a1a; }
|
||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
||||
.item-price { color:#2ecc71; font-weight:600; }
|
||||
@@ -182,9 +184,15 @@ class ProductsCrud extends HTMLElement {
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const item of filteredItems) {
|
||||
this._filteredItems = filteredItems; // Guardar referencia para Shift+Click
|
||||
|
||||
for (let i = 0; i < filteredItems.length; i++) {
|
||||
const item = filteredItems[i];
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.woo_product_id === item.woo_product_id ? " active" : "");
|
||||
const isSelected = this.selectedItems.some(s => s.woo_product_id === item.woo_product_id);
|
||||
const isSingleSelected = isSelected && this.selectedItems.length === 1;
|
||||
el.className = "item" + (isSelected ? " selected" : "") + (isSingleSelected ? " active" : "");
|
||||
el.dataset.index = i;
|
||||
|
||||
const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
|
||||
const sku = item.sku || "—";
|
||||
@@ -192,9 +200,13 @@ class ProductsCrud extends HTMLElement {
|
||||
const stockBadge = stock === "instock"
|
||||
? `<span class="badge stock">En stock</span>`
|
||||
: `<span class="badge nostock">Sin stock</span>`;
|
||||
|
||||
// Mostrar unidad actual si está definida
|
||||
const unit = item.sell_unit || item.payload?._sell_unit_override;
|
||||
const unitBadge = unit ? `<span class="badge" style="background:#1a3a5c;color:#7eb8e7;">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge}</div>
|
||||
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}</div>
|
||||
<div class="item-meta">
|
||||
<span class="item-price">${price}</span> ·
|
||||
SKU: ${sku} ·
|
||||
@@ -202,30 +214,78 @@ class ProductsCrud extends HTMLElement {
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.onclick = () => {
|
||||
this.selected = item;
|
||||
this.renderList();
|
||||
this.renderDetail();
|
||||
// Scroll detail panel to top
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
if (detail) detail.scrollTop = 0;
|
||||
};
|
||||
el.onclick = (e) => this.handleItemClick(e, item, i);
|
||||
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
handleItemClick(e, item, index) {
|
||||
if (e.shiftKey && this.lastClickedIndex >= 0) {
|
||||
// Shift+Click: seleccionar rango
|
||||
const start = Math.min(this.lastClickedIndex, index);
|
||||
const end = Math.max(this.lastClickedIndex, index);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const rangeItem = this._filteredItems[i];
|
||||
if (!this.selectedItems.some(s => s.woo_product_id === rangeItem.woo_product_id)) {
|
||||
this.selectedItems.push(rangeItem);
|
||||
}
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Ctrl+Click: toggle individual
|
||||
const idx = this.selectedItems.findIndex(s => s.woo_product_id === item.woo_product_id);
|
||||
if (idx >= 0) {
|
||||
this.selectedItems.splice(idx, 1);
|
||||
} else {
|
||||
this.selectedItems.push(item);
|
||||
}
|
||||
} else {
|
||||
// Click normal: selección única
|
||||
this.selectedItems = [item];
|
||||
}
|
||||
|
||||
this.lastClickedIndex = index;
|
||||
this.renderList();
|
||||
this.renderDetail();
|
||||
|
||||
// Scroll detail panel to top
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
if (detail) detail.scrollTop = 0;
|
||||
}
|
||||
|
||||
renderDetail() {
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
|
||||
if (!this.selected) {
|
||||
if (!this.selectedItems.length) {
|
||||
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const p = this.selected;
|
||||
const categories = (p.payload?.categories || []).map(c => c.name).join(", ") || "—";
|
||||
const attributes = (p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
|
||||
// Si hay múltiples seleccionados, mostrar vista de edición masiva
|
||||
if (this.selectedItems.length > 1) {
|
||||
this.renderMultiDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
const p = this.selectedItems[0];
|
||||
|
||||
// Categorías: pueden estar en p.categories (string JSON) o p.payload.categories (array)
|
||||
let categoriesArray = [];
|
||||
if (p.categories) {
|
||||
try {
|
||||
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
|
||||
categoriesArray = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)];
|
||||
} catch { categoriesArray = [String(p.categories)]; }
|
||||
} else if (p.payload?.categories) {
|
||||
categoriesArray = p.payload.categories.map(c => c.name || c);
|
||||
}
|
||||
const categoriesText = categoriesArray.join(", ");
|
||||
|
||||
const attributes = (p.attributes_normalized || p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
|
||||
|
||||
// Determinar unidad actual (de payload o inferida)
|
||||
const currentUnit = p.sell_unit || p.payload?._sell_unit_override || this.inferUnit(p);
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="field">
|
||||
@@ -245,13 +305,31 @@ class ProductsCrud extends HTMLElement {
|
||||
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Categorías</label>
|
||||
<div class="field-value">${categories}</div>
|
||||
<label>Unidad de venta</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;">
|
||||
<option value="kg" ${currentUnit === "kg" ? "selected" : ""}>Por peso (kg)</option>
|
||||
<option value="unit" ${currentUnit === "unit" ? "selected" : ""}>Por unidad</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
Define si este producto se vende por peso o por unidad
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Categorías (separadas por coma)</label>
|
||||
<input type="text" id="categoriesInput" value="${categoriesText}" placeholder="ej: Carnes, Vacuno, Premium" style="width:100%;" />
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
Categorías del producto, separadas por coma
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Atributos</label>
|
||||
<div class="field-value">${attributes}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button id="saveProduct" style="width:100%;padding:10px;">Guardar cambios</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Última actualización</label>
|
||||
<div class="field-value">${p.refreshed_at ? new Date(p.refreshed_at).toLocaleString() : "—"}</div>
|
||||
@@ -261,6 +339,136 @@ class ProductsCrud extends HTMLElement {
|
||||
<div class="field-value json">${JSON.stringify(p.payload || {}, null, 2)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind save button
|
||||
this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct();
|
||||
}
|
||||
|
||||
async saveProduct() {
|
||||
if (this.selectedItems.length !== 1) return;
|
||||
|
||||
const p = this.selectedItems[0];
|
||||
const btn = this.shadowRoot.getElementById("saveProduct");
|
||||
const sellUnitSelect = this.shadowRoot.getElementById("sellUnit");
|
||||
const categoriesInput = this.shadowRoot.getElementById("categoriesInput");
|
||||
|
||||
const sell_unit = sellUnitSelect.value;
|
||||
const categories = categoriesInput.value.split(",").map(s => s.trim()).filter(Boolean);
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Guardando...";
|
||||
|
||||
try {
|
||||
await api.updateProduct(p.woo_product_id, { sell_unit, categories });
|
||||
|
||||
// Actualizar localmente
|
||||
p.sell_unit = sell_unit;
|
||||
p.categories = JSON.stringify(categories.map(name => ({ name })));
|
||||
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
|
||||
if (idx >= 0) {
|
||||
this.items[idx].sell_unit = sell_unit;
|
||||
this.items[idx].categories = p.categories;
|
||||
}
|
||||
|
||||
btn.textContent = "Guardado!";
|
||||
this.renderList();
|
||||
setTimeout(() => { btn.textContent = "Guardar cambios"; btn.disabled = false; }, 1500);
|
||||
} catch (e) {
|
||||
console.error("Error saving product:", e);
|
||||
alert("Error guardando: " + (e.message || e));
|
||||
btn.textContent = "Guardar cambios";
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderMultiDetail() {
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
const count = this.selectedItems.length;
|
||||
const names = this.selectedItems.slice(0, 5).map(p => p.name).join(", ");
|
||||
const moreText = count > 5 ? ` y ${count - 5} más...` : "";
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Productos seleccionados</label>
|
||||
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">${names}${moreText}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Unidad de venta (para todos)</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<option value="kg">Por peso (kg)</option>
|
||||
<option value="unit">Por unidad</option>
|
||||
</select>
|
||||
<button id="saveUnit" style="padding:8px 16px;">Guardar para ${count}</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
Se aplicará a todos los productos seleccionados
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button id="clearSelection" class="secondary" style="width:100%;">Limpiar selección</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.shadowRoot.getElementById("saveUnit").onclick = () => this.saveProductUnit();
|
||||
this.shadowRoot.getElementById("clearSelection").onclick = () => {
|
||||
this.selectedItems = [];
|
||||
this.lastClickedIndex = -1;
|
||||
this.renderList();
|
||||
this.renderDetail();
|
||||
};
|
||||
}
|
||||
|
||||
inferUnit(p) {
|
||||
const name = String(p.name || "").toLowerCase();
|
||||
const cats = (p.payload?.categories || []).map(c => String(c.name || c.slug || "").toLowerCase());
|
||||
const allText = name + " " + cats.join(" ");
|
||||
|
||||
// Productos que típicamente se venden por unidad
|
||||
if (/chimichurri|provoleta|queso|pan|salsa|aderezo|condimento|especias?|vino|vinos|bebida|cerveza|gaseosa|whisky|ron|gin|vodka|fernet/i.test(allText)) {
|
||||
return "unit";
|
||||
}
|
||||
return "kg";
|
||||
}
|
||||
|
||||
async saveProductUnit() {
|
||||
if (!this.selectedItems.length) return;
|
||||
|
||||
const select = this.shadowRoot.getElementById("sellUnit");
|
||||
const btn = this.shadowRoot.getElementById("saveUnit");
|
||||
const unit = select.value;
|
||||
const count = this.selectedItems.length;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Guardando...";
|
||||
|
||||
try {
|
||||
// IDs de todos los productos seleccionados
|
||||
const wooProductIds = this.selectedItems.map(p => p.woo_product_id);
|
||||
|
||||
// Un solo request para todos
|
||||
await api.updateProductsUnit(wooProductIds, { sell_unit: unit });
|
||||
|
||||
// Actualizar localmente
|
||||
for (const p of this.selectedItems) {
|
||||
p.sell_unit = unit;
|
||||
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
|
||||
if (idx >= 0) this.items[idx].sell_unit = unit;
|
||||
}
|
||||
|
||||
btn.textContent = `Guardado ${count}!`;
|
||||
this.renderList(); // Actualizar badges en lista
|
||||
setTimeout(() => {
|
||||
btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar";
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
console.error("Error saving product unit:", e);
|
||||
alert("Error guardando: " + (e.message || e));
|
||||
btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar";
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,20 @@ class RecommendationsCrud extends HTMLElement {
|
||||
this.loading = false;
|
||||
this.searchQuery = "";
|
||||
this.editMode = null; // 'create' | 'edit' | null
|
||||
|
||||
// Cache de productos para el selector
|
||||
this.allProducts = [];
|
||||
this.productsLoaded = false;
|
||||
|
||||
// Productos seleccionados en el formulario
|
||||
this.selectedTriggerProducts = [];
|
||||
this.selectedRecommendedProducts = [];
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { display:grid; grid-template-columns:1fr 450px; gap:16px; height:100%; }
|
||||
.container { display:grid; grid-template-columns:1fr 500px; gap:16px; height:100%; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||
|
||||
@@ -55,6 +63,38 @@ class RecommendationsCrud extends HTMLElement {
|
||||
|
||||
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||||
.toggle input { width:auto; }
|
||||
|
||||
/* Product selector styles */
|
||||
.product-selector { position:relative; }
|
||||
.product-search { margin-bottom:8px; }
|
||||
.product-dropdown {
|
||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
||||
max-height:200px; overflow-y:auto; display:none;
|
||||
}
|
||||
.product-dropdown.open { display:block; }
|
||||
.product-option {
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
}
|
||||
.product-option:hover { background:#1a2535; }
|
||||
.product-option.selected { background:#1a3a5c; }
|
||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||
|
||||
.selected-products { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; min-height:30px; }
|
||||
.product-chip {
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
background:#253245; color:#e7eef7; padding:4px 8px 4px 12px;
|
||||
border-radius:999px; font-size:12px;
|
||||
}
|
||||
.product-chip .remove {
|
||||
cursor:pointer; width:16px; height:16px; border-radius:50%;
|
||||
background:#e74c3c; color:#fff; font-size:10px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.product-chip .remove:hover { background:#c0392b; }
|
||||
|
||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -72,7 +112,7 @@ class RecommendationsCrud extends HTMLElement {
|
||||
<div class="panel">
|
||||
<div class="panel-title" id="formTitle">Detalle</div>
|
||||
<div class="form" id="form">
|
||||
<div class="form-empty">Seleccioná una regla o creá una nueva</div>
|
||||
<div class="form-empty">Selecciona una regla o crea una nueva</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +129,19 @@ class RecommendationsCrud extends HTMLElement {
|
||||
this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm();
|
||||
|
||||
this.load();
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
async loadProducts() {
|
||||
if (this.productsLoaded) return;
|
||||
try {
|
||||
const data = await api.products({ limit: 2000 });
|
||||
this.allProducts = (data.items || []).filter(p => p.stock_status === "instock");
|
||||
this.productsLoaded = true;
|
||||
} catch (e) {
|
||||
console.error("Error loading products:", e);
|
||||
this.allProducts = [];
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
@@ -108,6 +161,11 @@ class RecommendationsCrud extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
getProductName(id) {
|
||||
const p = this.allProducts.find(x => x.woo_product_id === id);
|
||||
return p?.name || `Producto #${id}`;
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
@@ -126,10 +184,15 @@ class RecommendationsCrud extends HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
|
||||
|
||||
const trigger = item.trigger || {};
|
||||
const keywords = (trigger.keywords || []).join(", ") || "—";
|
||||
const queries = (item.queries || []).slice(0, 3).join(", ");
|
||||
const hasMore = (item.queries || []).length > 3;
|
||||
// Mostrar productos trigger
|
||||
const triggerIds = item.trigger_product_ids || [];
|
||||
const triggerNames = triggerIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
|
||||
const triggerMore = triggerIds.length > 3 ? ` (+${triggerIds.length - 3})` : "";
|
||||
|
||||
// Mostrar productos recomendados
|
||||
const recoIds = item.recommended_product_ids || [];
|
||||
const recoNames = recoIds.slice(0, 3).map(id => this.getProductName(id)).join(", ");
|
||||
const recoMore = recoIds.length > 3 ? ` (+${recoIds.length - 3})` : "";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-key">
|
||||
@@ -137,13 +200,15 @@ class RecommendationsCrud extends HTMLElement {
|
||||
<span class="badge ${item.active ? "active" : "inactive"}">${item.active ? "Activa" : "Inactiva"}</span>
|
||||
<span class="badge priority">P: ${item.priority}</span>
|
||||
</div>
|
||||
<div class="item-trigger">Keywords: ${keywords}</div>
|
||||
<div class="item-queries">→ ${queries}${hasMore ? "..." : ""}</div>
|
||||
<div class="item-trigger">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
|
||||
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
|
||||
`;
|
||||
|
||||
el.onclick = () => {
|
||||
this.selected = item;
|
||||
this.editMode = "edit";
|
||||
this.selectedTriggerProducts = [...(item.trigger_product_ids || [])];
|
||||
this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
};
|
||||
@@ -155,6 +220,8 @@ class RecommendationsCrud extends HTMLElement {
|
||||
showCreateForm() {
|
||||
this.selected = null;
|
||||
this.editMode = "create";
|
||||
this.selectedTriggerProducts = [];
|
||||
this.selectedRecommendedProducts = [];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
@@ -165,7 +232,7 @@ class RecommendationsCrud extends HTMLElement {
|
||||
|
||||
if (!this.editMode) {
|
||||
title.textContent = "Detalle";
|
||||
form.innerHTML = `<div class="form-empty">Seleccioná una regla o creá una nueva</div>`;
|
||||
form.innerHTML = `<div class="form-empty">Selecciona una regla o crea una nueva</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,31 +240,20 @@ class RecommendationsCrud extends HTMLElement {
|
||||
title.textContent = isCreate ? "Nueva Regla" : "Editar Regla";
|
||||
|
||||
const rule_key = this.selected?.rule_key || "";
|
||||
const trigger = this.selected?.trigger || {};
|
||||
const queries = this.selected?.queries || [];
|
||||
const ask_slots = this.selected?.ask_slots || [];
|
||||
const active = this.selected?.active !== false;
|
||||
const priority = this.selected?.priority || 100;
|
||||
|
||||
// Convert arrays to comma-separated strings for display
|
||||
const triggerKeywords = (trigger.keywords || []).join(", ");
|
||||
const queriesText = (queries || []).join(", ");
|
||||
const askSlotsText = Array.isArray(ask_slots)
|
||||
? ask_slots.map(s => typeof s === "string" ? s : s?.slot || s?.keyword || "").filter(Boolean).join(", ")
|
||||
: "";
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Rule Key (identificador unico)</label>
|
||||
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_core, alcohol_yes" />
|
||||
<div class="field-hint">Sin espacios, en minusculas con guiones bajos</div>
|
||||
<label>Nombre de la regla</label>
|
||||
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_recos, vinos_con_carne" />
|
||||
<div class="field-hint">Identificador unico, sin espacios</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Prioridad</label>
|
||||
<input type="number" id="priorityInput" value="${priority}" min="1" max="1000" />
|
||||
<div class="field-hint">Mayor = primero</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Estado</label>
|
||||
@@ -209,21 +265,23 @@ class RecommendationsCrud extends HTMLElement {
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Trigger (palabras clave)</label>
|
||||
<textarea id="triggerInput" placeholder="asado, parrilla, carne, bife...">${triggerKeywords}</textarea>
|
||||
<div class="field-hint">Palabras que activan esta regla, separadas por coma</div>
|
||||
<label>Cuando el cliente pide...</label>
|
||||
<div class="product-selector" id="triggerSelector">
|
||||
<input type="text" class="product-search" id="triggerSearch" placeholder="Buscar producto..." />
|
||||
<div class="product-dropdown" id="triggerDropdown"></div>
|
||||
<div class="selected-products" id="triggerSelected"></div>
|
||||
</div>
|
||||
<div class="field-hint">Productos que activan esta recomendacion</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Productos a recomendar</label>
|
||||
<textarea id="queriesInput" placeholder="provoleta, chimichurri, ensalada...">${queriesText}</textarea>
|
||||
<div class="field-hint">Productos a buscar cuando se activa la regla, separados por coma</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Preguntar sobre... (opcional)</label>
|
||||
<textarea id="askSlotsInput" placeholder="achuras, cerdo, vino...">${askSlotsText}</textarea>
|
||||
<div class="field-hint">El bot preguntara al usuario sobre estos temas de forma natural</div>
|
||||
<label>Recomendar estos productos...</label>
|
||||
<div class="product-selector" id="recoSelector">
|
||||
<input type="text" class="product-search" id="recoSearch" placeholder="Buscar producto..." />
|
||||
<div class="product-dropdown" id="recoDropdown"></div>
|
||||
<div class="selected-products" id="recoSelected"></div>
|
||||
</div>
|
||||
<div class="field-hint">Productos a sugerir al cliente</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -233,48 +291,145 @@ class RecommendationsCrud extends HTMLElement {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Setup event handlers
|
||||
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
|
||||
this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
|
||||
if (!isCreate) {
|
||||
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
|
||||
}
|
||||
|
||||
// Setup product selectors
|
||||
this.setupProductSelector("trigger", this.selectedTriggerProducts);
|
||||
this.setupProductSelector("reco", this.selectedRecommendedProducts);
|
||||
}
|
||||
|
||||
parseCommaSeparated(str) {
|
||||
return String(str || "")
|
||||
.split(",")
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
setupProductSelector(type, selectedIds) {
|
||||
const searchInput = this.shadowRoot.getElementById(`${type}Search`);
|
||||
const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`);
|
||||
const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`);
|
||||
|
||||
const renderSelected = () => {
|
||||
const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts;
|
||||
if (!ids.length) {
|
||||
selectedContainer.innerHTML = `<span class="empty-hint">Ningun producto seleccionado</span>`;
|
||||
return;
|
||||
}
|
||||
selectedContainer.innerHTML = ids.map(id => {
|
||||
const name = this.getProductName(id);
|
||||
return `<span class="product-chip" data-id="${id}">${name}<span class="remove">×</span></span>`;
|
||||
}).join("");
|
||||
|
||||
selectedContainer.querySelectorAll(".remove").forEach(btn => {
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(btn.parentElement.dataset.id, 10);
|
||||
if (type === "trigger") {
|
||||
this.selectedTriggerProducts = this.selectedTriggerProducts.filter(x => x !== id);
|
||||
} else {
|
||||
this.selectedRecommendedProducts = this.selectedRecommendedProducts.filter(x => x !== id);
|
||||
}
|
||||
renderSelected();
|
||||
renderDropdown(searchInput.value);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const renderDropdown = (query) => {
|
||||
const q = (query || "").toLowerCase().trim();
|
||||
const selectedSet = new Set(type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts);
|
||||
|
||||
let filtered = this.allProducts;
|
||||
if (q) {
|
||||
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
|
||||
}
|
||||
filtered = filtered.slice(0, 50); // Limit for performance
|
||||
|
||||
if (!q && !filtered.length) {
|
||||
dropdown.classList.remove("open");
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = filtered.map(p => {
|
||||
const isSelected = selectedSet.has(p.woo_product_id);
|
||||
return `
|
||||
<div class="product-option ${isSelected ? "selected" : ""}" data-id="${p.woo_product_id}">
|
||||
<span>${p.name}</span>
|
||||
<span class="price">$${p.price || 0}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
dropdown.querySelectorAll(".product-option").forEach(opt => {
|
||||
opt.onclick = () => {
|
||||
const id = parseInt(opt.dataset.id, 10);
|
||||
if (type === "trigger") {
|
||||
if (!this.selectedTriggerProducts.includes(id)) {
|
||||
this.selectedTriggerProducts.push(id);
|
||||
}
|
||||
} else {
|
||||
if (!this.selectedRecommendedProducts.includes(id)) {
|
||||
this.selectedRecommendedProducts.push(id);
|
||||
}
|
||||
}
|
||||
searchInput.value = "";
|
||||
dropdown.classList.remove("open");
|
||||
renderSelected();
|
||||
};
|
||||
});
|
||||
|
||||
dropdown.classList.add("open");
|
||||
};
|
||||
|
||||
searchInput.oninput = () => {
|
||||
clearTimeout(this[`_${type}Timer`]);
|
||||
this[`_${type}Timer`] = setTimeout(() => renderDropdown(searchInput.value), 150);
|
||||
};
|
||||
|
||||
searchInput.onfocus = () => {
|
||||
if (searchInput.value || this.allProducts.length) {
|
||||
renderDropdown(searchInput.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
renderSelected();
|
||||
}
|
||||
|
||||
async save() {
|
||||
const ruleKey = this.shadowRoot.getElementById("ruleKeyInput").value.trim().toLowerCase().replace(/\s+/g, "_");
|
||||
const priority = parseInt(this.shadowRoot.getElementById("priorityInput").value, 10) || 100;
|
||||
const active = this.shadowRoot.getElementById("activeInput").checked;
|
||||
|
||||
// Parse comma-separated values into arrays
|
||||
const triggerKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("triggerInput").value);
|
||||
const queries = this.parseCommaSeparated(this.shadowRoot.getElementById("queriesInput").value);
|
||||
const askSlotsKeywords = this.parseCommaSeparated(this.shadowRoot.getElementById("askSlotsInput").value);
|
||||
|
||||
if (!ruleKey) {
|
||||
alert("El rule_key es requerido");
|
||||
alert("El nombre de la regla es requerido");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build trigger object with keywords array
|
||||
const trigger = triggerKeywords.length > 0 ? { keywords: triggerKeywords } : {};
|
||||
|
||||
// Ask slots as simple array of keywords (LLM will formulate questions naturally)
|
||||
const ask_slots = askSlotsKeywords;
|
||||
if (!this.selectedTriggerProducts.length) {
|
||||
alert("Selecciona al menos un producto trigger");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedRecommendedProducts.length) {
|
||||
alert("Selecciona al menos un producto para recomendar");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
rule_key: ruleKey,
|
||||
trigger,
|
||||
queries,
|
||||
ask_slots,
|
||||
trigger: {}, // Legacy field, keep empty
|
||||
queries: [], // Legacy field, keep empty
|
||||
ask_slots: [],
|
||||
active,
|
||||
priority,
|
||||
trigger_product_ids: this.selectedTriggerProducts,
|
||||
recommended_product_ids: this.selectedRecommendedProducts,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -312,6 +467,8 @@ class RecommendationsCrud extends HTMLElement {
|
||||
cancel() {
|
||||
this.editMode = null;
|
||||
this.selected = null;
|
||||
this.selectedTriggerProducts = [];
|
||||
this.selectedRecommendedProducts = [];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ class RunTimeline extends HTMLElement {
|
||||
this.shadowRoot.getElementById("refresh").onclick = () => this.loadMessages();
|
||||
|
||||
this._unsubSel = on("ui:selectedChat", async ({ chat_id }) => {
|
||||
// Si es el mismo chat, no recargar (para no borrar burbujas optimistas)
|
||||
if (this.chatId === chat_id) return;
|
||||
this.chatId = chat_id;
|
||||
await this.loadMessages();
|
||||
});
|
||||
@@ -76,7 +78,13 @@ class RunTimeline extends HTMLElement {
|
||||
|
||||
// Listen for optimistic messages (show bubble immediately before API response)
|
||||
this._unsubOptimistic = on("message:optimistic", (msg) => {
|
||||
if (!this.chatId || msg.chat_id !== this.chatId) return;
|
||||
// Si no hay chatId seteado, setearlo al del mensaje
|
||||
if (!this.chatId) {
|
||||
this.chatId = msg.chat_id;
|
||||
this.shadowRoot.getElementById("chat").textContent = msg.chat_id;
|
||||
this.shadowRoot.getElementById("meta").textContent = "Nueva conversación";
|
||||
}
|
||||
if (msg.chat_id !== this.chatId) return;
|
||||
this.addOptimisticBubble(msg);
|
||||
});
|
||||
}
|
||||
@@ -135,8 +143,20 @@ class RunTimeline extends HTMLElement {
|
||||
meta.textContent = `Mostrando historial (últimos ${this.items.length}).`;
|
||||
count.textContent = this.items.length ? `${this.items.length} msgs` : "";
|
||||
|
||||
// Capturar info de burbujas optimistas antes de limpiar
|
||||
const optimisticBubbles = [...log.querySelectorAll('.bubble[data-message-id^="optimistic-"]')];
|
||||
const optimisticTexts = optimisticBubbles.map(b => {
|
||||
const textEl = b.querySelector("div:not(.name):not(.meta)");
|
||||
return (textEl ? textEl.textContent : "").trim().toLowerCase();
|
||||
});
|
||||
|
||||
log.innerHTML = "";
|
||||
|
||||
// Obtener textos de mensajes IN del servidor (normalizados para comparación)
|
||||
const serverUserTexts = this.items
|
||||
.filter(m => m.direction === "in")
|
||||
.map(m => (m.text || "").trim().toLowerCase());
|
||||
|
||||
for (const m of this.items) {
|
||||
const who = m.direction === "in" ? "user" : "bot";
|
||||
const isErr = this.isErrorMsg(m);
|
||||
@@ -164,8 +184,23 @@ class RunTimeline extends HTMLElement {
|
||||
log.appendChild(bubble);
|
||||
}
|
||||
|
||||
// auto-scroll
|
||||
log.scrollTop = log.scrollHeight;
|
||||
// Re-agregar burbujas optimistas SOLO si su texto no está ya en los mensajes del servidor
|
||||
// Comparación case-insensitive y trimmed
|
||||
let addedOptimistic = false;
|
||||
for (let i = 0; i < optimisticBubbles.length; i++) {
|
||||
const optText = optimisticTexts[i];
|
||||
// Si el texto ya existe en un mensaje del servidor, no re-agregar
|
||||
if (serverUserTexts.includes(optText)) {
|
||||
continue;
|
||||
}
|
||||
log.appendChild(optimisticBubbles[i]);
|
||||
addedOptimistic = true;
|
||||
}
|
||||
|
||||
// auto-scroll solo si agregamos burbujas optimistas nuevas
|
||||
if (addedOptimistic) {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.emitLayout());
|
||||
this.bindScroll(log);
|
||||
@@ -193,29 +228,7 @@ class RunTimeline extends HTMLElement {
|
||||
};
|
||||
});
|
||||
emit("ui:bubblesLayout", { chat_id: this.chatId, items });
|
||||
// #region agent log
|
||||
fetch("http://127.0.0.1:7242/ingest/86c7b1cd-c414-4eae-852c-08e57e562b3b", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sessionId: "debug-session",
|
||||
runId: "pre-fix",
|
||||
hypothesisId: "H15",
|
||||
location: "run-timeline.js:180",
|
||||
message: "bubbles_layout",
|
||||
data: {
|
||||
count: items.length,
|
||||
chat_id: this.chatId || null,
|
||||
scroll_height: log.scrollHeight,
|
||||
client_height: log.clientHeight,
|
||||
host_height: this.getBoundingClientRect().height,
|
||||
box_height: box ? box.getBoundingClientRect().height : null,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// #endregion
|
||||
}
|
||||
}
|
||||
|
||||
highlightMessage(message_id) {
|
||||
const log = this.shadowRoot.getElementById("log");
|
||||
@@ -275,7 +288,12 @@ class RunTimeline extends HTMLElement {
|
||||
bubble.appendChild(metaEl);
|
||||
|
||||
log.appendChild(bubble);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
||||
// Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px)
|
||||
const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100;
|
||||
if (wasNearBottom) {
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// Emit layout update
|
||||
requestAnimationFrame(() => this.emitLayout());
|
||||
|
||||
@@ -9,6 +9,7 @@ class UsersCrud extends HTMLElement {
|
||||
this.selected = null;
|
||||
this.loading = false;
|
||||
this.searchQuery = "";
|
||||
this.wooFilter = false;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@@ -49,7 +50,9 @@ class UsersCrud extends HTMLElement {
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
|
||||
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
||||
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; }
|
||||
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||
.stat:hover { border-color:#1f6feb; }
|
||||
.stat.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
|
||||
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
|
||||
</style>
|
||||
@@ -58,11 +61,11 @@ class UsersCrud extends HTMLElement {
|
||||
<div class="panel">
|
||||
<div class="panel-title">Usuarios</div>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat" id="statTotal">
|
||||
<div class="stat-value" id="totalCount">—</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat" id="statWoo">
|
||||
<div class="stat-value" id="wooCount">—</div>
|
||||
<div class="stat-label">Con Woo ID</div>
|
||||
</div>
|
||||
@@ -92,9 +95,29 @@ class UsersCrud extends HTMLElement {
|
||||
this._searchTimer = setTimeout(() => this.load(), 300);
|
||||
};
|
||||
|
||||
// Stats click handlers
|
||||
this.shadowRoot.getElementById("statTotal").onclick = () => {
|
||||
this.wooFilter = false;
|
||||
this.renderList();
|
||||
this.updateStatStyles();
|
||||
};
|
||||
|
||||
this.shadowRoot.getElementById("statWoo").onclick = () => {
|
||||
this.wooFilter = !this.wooFilter;
|
||||
this.renderList();
|
||||
this.updateStatStyles();
|
||||
};
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
updateStatStyles() {
|
||||
const statTotal = this.shadowRoot.getElementById("statTotal");
|
||||
const statWoo = this.shadowRoot.getElementById("statWoo");
|
||||
statTotal.classList.toggle("active", !this.wooFilter);
|
||||
statWoo.classList.toggle("active", this.wooFilter);
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.renderList();
|
||||
@@ -129,13 +152,18 @@ class UsersCrud extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.items.length) {
|
||||
// Filter by woo ID if filter is active
|
||||
const filteredItems = this.wooFilter
|
||||
? this.items.filter(u => u.external_customer_id)
|
||||
: this.items;
|
||||
|
||||
if (!filteredItems.length) {
|
||||
list.innerHTML = `<div class="loading">No se encontraron usuarios</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const item of this.items) {
|
||||
for (const item of filteredItems) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.chat_id === item.chat_id ? " active" : "");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user