productos, equivalencias, cross-sell y cantidades
This commit is contained in:
@@ -7,6 +7,7 @@ import "./components/users-crud.js";
|
||||
import "./components/products-crud.js";
|
||||
import "./components/aliases-crud.js";
|
||||
import "./components/recommendations-crud.js";
|
||||
import "./components/quantities-crud.js";
|
||||
import { connectSSE } from "./lib/sse.js";
|
||||
|
||||
connectSSE();
|
||||
|
||||
@@ -10,12 +10,15 @@ class AliasesCrud extends HTMLElement {
|
||||
this.loading = false;
|
||||
this.searchQuery = "";
|
||||
this.editMode = null; // 'create' | 'edit' | null
|
||||
|
||||
// Productos mapeados con scores
|
||||
this.productMappings = [];
|
||||
|
||||
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 400px; gap:16px; height:100%; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; 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; }
|
||||
|
||||
@@ -29,13 +32,14 @@ class AliasesCrud extends HTMLElement {
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.small { padding:4px 8px; font-size:11px; }
|
||||
|
||||
.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:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
|
||||
.item-product { font-size:12px; color:#8aa0b5; }
|
||||
.item-products { font-size:12px; color:#8aa0b5; }
|
||||
.item-boost { color:#2ecc71; font-size:11px; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; }
|
||||
@@ -46,6 +50,35 @@ class AliasesCrud extends HTMLElement {
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
|
||||
/* Product mappings table */
|
||||
.mappings-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||
.mappings-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
|
||||
.mappings-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
|
||||
.mappings-table input[type="number"] { width:70px; padding:6px 8px; font-size:12px; }
|
||||
.mappings-table .product-name { font-size:13px; color:#e7eef7; }
|
||||
.mappings-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
|
||||
|
||||
.add-mapping-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
||||
.add-mapping-row .field { margin-bottom:0; }
|
||||
|
||||
/* Product selector */
|
||||
.product-selector { position:relative; }
|
||||
.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 .price { font-size:11px; color:#8aa0b5; }
|
||||
|
||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
||||
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:#253245; color:#8aa0b5; margin-left:4px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
@@ -63,7 +96,7 @@ class AliasesCrud extends HTMLElement {
|
||||
<div class="panel">
|
||||
<div class="panel-title" id="formTitle">Detalle</div>
|
||||
<div class="form" id="form">
|
||||
<div class="form-empty">Seleccioná un alias o creá uno nuevo</div>
|
||||
<div class="form-empty">Selecciona un alias o crea uno nuevo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +143,11 @@ class AliasesCrud extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
getProductName(id) {
|
||||
const p = this.products.find(x => x.woo_product_id === id);
|
||||
return p?.name || `Producto #${id}`;
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
@@ -128,18 +166,40 @@ class AliasesCrud extends HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
|
||||
|
||||
const product = this.products.find(p => p.woo_product_id === item.woo_product_id);
|
||||
const productName = product?.name || `ID: ${item.woo_product_id || "—"}`;
|
||||
const boost = item.boost ? `+${item.boost}` : "";
|
||||
// Mostrar productos mapeados
|
||||
const mappings = item.product_mappings || [];
|
||||
let productsHtml = "";
|
||||
|
||||
if (mappings.length > 0) {
|
||||
const productNames = mappings.slice(0, 3).map(m => {
|
||||
const name = this.getProductName(m.woo_product_id);
|
||||
return `${name} (${m.score})`;
|
||||
}).join(", ");
|
||||
const more = mappings.length > 3 ? ` +${mappings.length - 3} más` : "";
|
||||
productsHtml = `<div class="item-products">→ ${productNames}${more}</div>`;
|
||||
} else if (item.woo_product_id) {
|
||||
// Fallback al producto único legacy
|
||||
const productName = this.getProductName(item.woo_product_id);
|
||||
const boost = item.boost ? ` <span class="item-boost">(boost: +${item.boost})</span>` : "";
|
||||
productsHtml = `<div class="item-products">→ ${productName}${boost}</div>`;
|
||||
} else {
|
||||
productsHtml = `<div class="item-products">→ Sin productos</div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-alias">"${item.alias}"</div>
|
||||
<div class="item-product">→ ${productName} ${boost ? `<span class="item-boost">(boost: ${boost})</span>` : ""}</div>
|
||||
<div class="item-alias">"${item.alias}" <span class="badge">${mappings.length || (item.woo_product_id ? 1 : 0)} productos</span></div>
|
||||
${productsHtml}
|
||||
`;
|
||||
|
||||
el.onclick = () => {
|
||||
this.selected = item;
|
||||
this.editMode = "edit";
|
||||
// Cargar mappings
|
||||
this.productMappings = [...(item.product_mappings || [])];
|
||||
// Si no hay mappings pero hay woo_product_id, crear uno por defecto
|
||||
if (this.productMappings.length === 0 && item.woo_product_id) {
|
||||
this.productMappings = [{ woo_product_id: item.woo_product_id, score: item.boost || 1.0 }];
|
||||
}
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
};
|
||||
@@ -151,6 +211,7 @@ class AliasesCrud extends HTMLElement {
|
||||
showCreateForm() {
|
||||
this.selected = null;
|
||||
this.editMode = "create";
|
||||
this.productMappings = [];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
@@ -161,7 +222,7 @@ class AliasesCrud extends HTMLElement {
|
||||
|
||||
if (!this.editMode) {
|
||||
title.textContent = "Detalle";
|
||||
form.innerHTML = `<div class="form-empty">Seleccioná un alias o creá uno nuevo</div>`;
|
||||
form.innerHTML = `<div class="form-empty">Selecciona un alias o crea uno nuevo</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,36 +230,49 @@ class AliasesCrud extends HTMLElement {
|
||||
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
|
||||
|
||||
const alias = this.selected?.alias || "";
|
||||
const wooProductId = this.selected?.woo_product_id || "";
|
||||
const boost = this.selected?.boost || 0;
|
||||
const categoryHint = this.selected?.category_hint || "";
|
||||
|
||||
const productOptions = this.products.map(p =>
|
||||
`<option value="${p.woo_product_id}" ${p.woo_product_id === wooProductId ? "selected" : ""}>${p.name}</option>`
|
||||
).join("");
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Alias (lo que dice el usuario)</label>
|
||||
<input type="text" id="aliasInput" value="${alias}" ${isCreate ? "" : "disabled"} placeholder="ej: chimi, vacio, bife" />
|
||||
<div class="field-hint">Sin tildes, en minusculas. Ej: "chimi" mapea a "Chimichurri"</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Producto destino</label>
|
||||
<select id="productInput">
|
||||
<option value="">— Seleccionar producto —</option>
|
||||
${productOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Boost (puntuacion extra)</label>
|
||||
<input type="number" id="boostInput" value="${boost}" min="0" max="10" step="0.1" />
|
||||
<div class="field-hint">Valor entre 0 y 10. Mayor boost = mayor prioridad en resultados</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Categoria hint (opcional)</label>
|
||||
<input type="text" id="categoryInput" value="${categoryHint}" placeholder="ej: carnes, bebidas" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Productos que matchean</label>
|
||||
<table class="mappings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th>Score (0-1)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mappingsTableBody">
|
||||
${this.renderMappingsRows()}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="add-mapping-row">
|
||||
<div class="field" style="flex:2">
|
||||
<div class="product-selector" id="mappingSelector">
|
||||
<input type="text" id="mappingSearch" placeholder="Buscar producto para agregar..." />
|
||||
<div class="product-dropdown" id="mappingDropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="flex:0">
|
||||
<button id="addMappingBtn" class="secondary small">+ Agregar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint">Mayor score = mayor prioridad. Si hay ambiguedad, se pregunta al usuario.</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
|
||||
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
|
||||
@@ -211,28 +285,147 @@ class AliasesCrud extends HTMLElement {
|
||||
if (!isCreate) {
|
||||
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
|
||||
}
|
||||
|
||||
this.setupMappingsTable();
|
||||
this.setupAddMappingSelector();
|
||||
}
|
||||
|
||||
renderMappingsRows() {
|
||||
if (!this.productMappings.length) {
|
||||
return `<tr><td colspan="3" class="empty-hint">No hay productos mapeados</td></tr>`;
|
||||
}
|
||||
|
||||
return this.productMappings.map((mapping, idx) => {
|
||||
const name = this.getProductName(mapping.woo_product_id);
|
||||
return `
|
||||
<tr data-idx="${idx}">
|
||||
<td class="product-name">${name}</td>
|
||||
<td><input type="number" class="mapping-score" value="${mapping.score || 1}" step="0.1" min="0" max="1" /></td>
|
||||
<td><button class="btn-remove small danger" data-idx="${idx}">×</button></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
setupMappingsTable() {
|
||||
const tbody = this.shadowRoot.getElementById("mappingsTableBody");
|
||||
if (!tbody) return;
|
||||
|
||||
// Handle score changes
|
||||
tbody.querySelectorAll(".mapping-score").forEach((input, idx) => {
|
||||
input.onchange = () => {
|
||||
this.productMappings[idx].score = parseFloat(input.value) || 1.0;
|
||||
};
|
||||
});
|
||||
|
||||
// Handle remove buttons
|
||||
tbody.querySelectorAll(".btn-remove").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
this.productMappings.splice(idx, 1);
|
||||
this.renderForm();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setupAddMappingSelector() {
|
||||
const searchInput = this.shadowRoot.getElementById("mappingSearch");
|
||||
const dropdown = this.shadowRoot.getElementById("mappingDropdown");
|
||||
const addBtn = this.shadowRoot.getElementById("addMappingBtn");
|
||||
|
||||
if (!searchInput || !dropdown) return;
|
||||
|
||||
let selectedProductId = null;
|
||||
|
||||
const renderDropdown = (query) => {
|
||||
const q = (query || "").toLowerCase().trim();
|
||||
const existingIds = new Set(this.productMappings.map(m => m.woo_product_id));
|
||||
|
||||
let filtered = this.products.filter(p => !existingIds.has(p.woo_product_id));
|
||||
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}">
|
||||
<span>${p.name}</span>
|
||||
<span class="price">$${p.price || 0}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
dropdown.querySelectorAll(".product-option").forEach(opt => {
|
||||
opt.onclick = () => {
|
||||
selectedProductId = parseInt(opt.dataset.id, 10);
|
||||
searchInput.value = this.getProductName(selectedProductId);
|
||||
dropdown.classList.remove("open");
|
||||
};
|
||||
});
|
||||
|
||||
dropdown.classList.add("open");
|
||||
};
|
||||
|
||||
searchInput.oninput = () => {
|
||||
selectedProductId = null;
|
||||
clearTimeout(this._mappingTimer);
|
||||
this._mappingTimer = setTimeout(() => renderDropdown(searchInput.value), 150);
|
||||
};
|
||||
|
||||
searchInput.onfocus = () => renderDropdown(searchInput.value);
|
||||
|
||||
addBtn.onclick = () => {
|
||||
if (!selectedProductId) {
|
||||
alert("Selecciona un producto primero");
|
||||
return;
|
||||
}
|
||||
|
||||
this.productMappings.push({
|
||||
woo_product_id: selectedProductId,
|
||||
score: 1.0,
|
||||
});
|
||||
|
||||
searchInput.value = "";
|
||||
selectedProductId = null;
|
||||
this.renderForm();
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.shadowRoot.getElementById("mappingSelector")?.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async save() {
|
||||
const aliasInput = this.shadowRoot.getElementById("aliasInput").value.trim().toLowerCase();
|
||||
const productInput = this.shadowRoot.getElementById("productInput").value;
|
||||
const boostInput = parseFloat(this.shadowRoot.getElementById("boostInput").value) || 0;
|
||||
const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
|
||||
|
||||
if (!aliasInput) {
|
||||
alert("El alias es requerido");
|
||||
return;
|
||||
}
|
||||
if (!productInput) {
|
||||
alert("Seleccioná un producto");
|
||||
|
||||
if (!this.productMappings.length) {
|
||||
alert("Agrega al menos un producto");
|
||||
return;
|
||||
}
|
||||
|
||||
// Usar el primer producto con mayor score como woo_product_id principal (legacy)
|
||||
const sortedMappings = [...this.productMappings].sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
const primaryProduct = sortedMappings[0];
|
||||
|
||||
const data = {
|
||||
alias: aliasInput,
|
||||
woo_product_id: parseInt(productInput, 10),
|
||||
boost: boostInput,
|
||||
woo_product_id: primaryProduct.woo_product_id,
|
||||
boost: primaryProduct.score || 1.0,
|
||||
category_hint: categoryInput || null,
|
||||
product_mappings: this.productMappings,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -243,6 +436,7 @@ class AliasesCrud extends HTMLElement {
|
||||
}
|
||||
this.editMode = null;
|
||||
this.selected = null;
|
||||
this.productMappings = [];
|
||||
await this.load();
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
@@ -259,6 +453,7 @@ class AliasesCrud extends HTMLElement {
|
||||
await api.deleteAlias(this.selected.alias);
|
||||
this.editMode = null;
|
||||
this.selected = null;
|
||||
this.productMappings = [];
|
||||
await this.load();
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
@@ -270,6 +465,7 @@ class AliasesCrud extends HTMLElement {
|
||||
cancel() {
|
||||
this.editMode = null;
|
||||
this.selected = null;
|
||||
this.productMappings = [];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
@@ -170,14 +170,50 @@ class ConversationInspector extends HTMLElement {
|
||||
if (!list.length) return "—";
|
||||
return list
|
||||
.map((it) => {
|
||||
const label = it.label || it.name || `#${it.product_id}`;
|
||||
const qty = it.quantity != null ? `${it.quantity}` : "?";
|
||||
const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
|
||||
const qty = it.quantity ?? it.qty ?? "?";
|
||||
const unit = it.unit || "";
|
||||
return `${label} (${qty}${unit ? " " + unit : ""})`;
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
formatOrder(cart, pending, order) {
|
||||
const parts = [];
|
||||
|
||||
// Cart items
|
||||
const cartList = Array.isArray(cart) ? cart : [];
|
||||
if (cartList.length > 0) {
|
||||
const cartStr = cartList
|
||||
.map((it) => {
|
||||
const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
|
||||
const qty = it.quantity ?? it.qty ?? "?";
|
||||
const unit = it.unit || "";
|
||||
return `${label} (${qty}${unit ? " " + unit : ""})`;
|
||||
})
|
||||
.join(" · ");
|
||||
parts.push(cartStr);
|
||||
}
|
||||
|
||||
// Pending items
|
||||
const pendingList = Array.isArray(pending) ? pending : [];
|
||||
const activePending = pendingList.filter(p => p.status !== "READY");
|
||||
if (activePending.length > 0) {
|
||||
parts.push(`[${activePending.length} pendiente(s)]`);
|
||||
}
|
||||
|
||||
// Checkout info
|
||||
const checkoutInfo = [];
|
||||
if (order?.is_delivery === true) checkoutInfo.push("🚚");
|
||||
if (order?.is_delivery === false) checkoutInfo.push("🏪");
|
||||
if (order?.payment_type === "cash") checkoutInfo.push("💵");
|
||||
if (order?.payment_type === "link") checkoutInfo.push("💳");
|
||||
if (order?.is_paid) checkoutInfo.push("✅");
|
||||
if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
|
||||
|
||||
return parts.length ? parts.join(" ") : "—";
|
||||
}
|
||||
|
||||
toolSummary(tools = []) {
|
||||
return tools.map((t) => ({
|
||||
type: t.type || t.name || "tool",
|
||||
@@ -218,7 +254,10 @@ 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?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
|
||||
// Usar orden nueva si está disponible, sino fallback a formato viejo
|
||||
const order = run?.order || {};
|
||||
const basket = order.cart || run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
|
||||
const pendingItems = order.pending || [];
|
||||
const tools = this.toolSummary(run?.tools || []);
|
||||
|
||||
const llmMeta = run?.llm_output?._llm || null;
|
||||
@@ -240,7 +279,7 @@ class ConversationInspector extends HTMLElement {
|
||||
<div class="k">NLU</div>
|
||||
<div class="v">${dir === "out" && llmMeta ? llmNote : "—"}</div>
|
||||
</div>
|
||||
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatCart(basket) : "—"}</div>
|
||||
<div class="cart"><strong>Carrito:</strong> ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}</div>
|
||||
<div class="chips">
|
||||
${tools
|
||||
.map(
|
||||
|
||||
@@ -16,7 +16,7 @@ class ConversationsCrud extends HTMLElement {
|
||||
<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 1fr; 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; }
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ class OpsShell extends HTMLElement {
|
||||
<button class="nav-btn" data-view="users">Usuarios</button>
|
||||
<button class="nav-btn" data-view="products">Productos</button>
|
||||
<button class="nav-btn" data-view="aliases">Equivalencias</button>
|
||||
<button class="nav-btn" data-view="recommendations">Recomendaciones</button>
|
||||
<button class="nav-btn" data-view="crosssell">Cross-sell</button>
|
||||
<button class="nav-btn" data-view="quantities">Cantidades</button>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<div class="status" id="sseStatus">SSE: connecting…</div>
|
||||
@@ -81,9 +82,15 @@ class OpsShell extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewRecommendations" class="view">
|
||||
<div id="viewCrosssell" class="view">
|
||||
<div class="layout-crud">
|
||||
<recommendations-crud></recommendations-crud>
|
||||
<recommendations-crud rule-type="crosssell"></recommendations-crud>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewQuantities" class="view">
|
||||
<div class="layout-crud">
|
||||
<quantities-crud></quantities-crud>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ class ProductsCrud extends HTMLElement {
|
||||
<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 400px; gap:16px; height:100%; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; 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; }
|
||||
|
||||
@@ -165,6 +165,34 @@ class ProductsCrud extends HTMLElement {
|
||||
this.shadowRoot.getElementById("inStockCount").textContent = inStock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae todas las categorías únicas de todos los productos
|
||||
*/
|
||||
getAllCategories() {
|
||||
const categoriesSet = new Set();
|
||||
|
||||
for (const p of this.items) {
|
||||
try {
|
||||
if (p.categories) {
|
||||
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
|
||||
if (Array.isArray(cats)) {
|
||||
cats.forEach(c => {
|
||||
const name = c.name || c;
|
||||
if (name) categoriesSet.add(name);
|
||||
});
|
||||
}
|
||||
} else if (p.payload?.categories && Array.isArray(p.payload.categories)) {
|
||||
p.payload.categories.forEach(c => {
|
||||
const name = c.name || c;
|
||||
if (name) categoriesSet.add(name);
|
||||
});
|
||||
}
|
||||
} catch (e) { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
return [...categoriesSet].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
@@ -195,7 +223,8 @@ class ProductsCrud extends HTMLElement {
|
||||
el.dataset.index = i;
|
||||
|
||||
const price = item.price != null ? `$${Number(item.price).toLocaleString()}` : "—";
|
||||
const sku = item.sku || "—";
|
||||
// SKU: puede venir de item.sku, item.payload.SKU (mayúsculas), o item.slug
|
||||
const sku = item.sku || item.payload?.SKU || item.payload?.sku || item.slug || "—";
|
||||
const stock = item.stock_status || item.payload?.stock_status || "unknown";
|
||||
const stockBadge = stock === "instock"
|
||||
? `<span class="badge stock">En stock</span>`
|
||||
@@ -221,6 +250,8 @@ class ProductsCrud extends HTMLElement {
|
||||
}
|
||||
|
||||
handleItemClick(e, item, index) {
|
||||
console.log("[products-crud] handleItemClick", { shift: e.shiftKey, ctrl: e.ctrlKey, index, item: item?.name });
|
||||
|
||||
if (e.shiftKey && this.lastClickedIndex >= 0) {
|
||||
// Shift+Click: seleccionar rango
|
||||
const start = Math.min(this.lastClickedIndex, index);
|
||||
@@ -245,17 +276,37 @@ class ProductsCrud extends HTMLElement {
|
||||
this.selectedItems = [item];
|
||||
}
|
||||
|
||||
console.log("[products-crud] after click, selectedItems:", this.selectedItems.length, this.selectedItems.map(s => s.name));
|
||||
|
||||
this.lastClickedIndex = index;
|
||||
this.renderList();
|
||||
this.renderDetail();
|
||||
try {
|
||||
this.renderDetail();
|
||||
} catch (err) {
|
||||
console.error("[products-crud] Error in renderDetail:", err);
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
detail.innerHTML = `<div class="detail-empty" style="color:#e74c3c;">Error: ${err.message}</div>`;
|
||||
}
|
||||
|
||||
// Scroll detail panel to top
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
if (detail) detail.scrollTop = 0;
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
renderDetail() {
|
||||
const detail = this.shadowRoot.getElementById("detail");
|
||||
|
||||
console.log("[products-crud] renderDetail called, selectedItems:", this.selectedItems.length);
|
||||
|
||||
if (!this.selectedItems.length) {
|
||||
detail.innerHTML = `<div class="detail-empty">Seleccioná un producto para ver detalles</div>`;
|
||||
@@ -264,33 +315,54 @@ class ProductsCrud extends HTMLElement {
|
||||
|
||||
// Si hay múltiples seleccionados, mostrar vista de edición masiva
|
||||
if (this.selectedItems.length > 1) {
|
||||
console.log("[products-crud] rendering multi detail");
|
||||
this.renderMultiDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
const p = this.selectedItems[0];
|
||||
console.log("[products-crud] rendering single product:", p?.name, p?.woo_product_id);
|
||||
|
||||
if (!p) {
|
||||
detail.innerHTML = `<div class="detail-empty">Error: producto no encontrado</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Categorías: pueden estar en p.categories (string JSON) o p.payload.categories (array)
|
||||
let categoriesArray = [];
|
||||
if (p.categories) {
|
||||
try {
|
||||
try {
|
||||
if (p.categories) {
|
||||
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);
|
||||
} else if (p.payload?.categories && Array.isArray(p.payload.categories)) {
|
||||
categoriesArray = p.payload.categories.map(c => c.name || c);
|
||||
}
|
||||
} catch (e) {
|
||||
categoriesArray = p.categories ? [String(p.categories)] : [];
|
||||
}
|
||||
const categoriesText = categoriesArray.join(", ");
|
||||
const categoriesText = this.escapeHtml(categoriesArray.join(", "));
|
||||
|
||||
const attributes = (p.attributes_normalized || p.payload?.attributes || []).map(a => `${a.name}: ${a.options?.join(", ")}`).join("; ") || "—";
|
||||
const rawAttrs = p.attributes_normalized || p.payload?.attributes;
|
||||
const attrsArray = Array.isArray(rawAttrs) ? rawAttrs : [];
|
||||
const attributes = attrsArray.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);
|
||||
|
||||
// Stock info
|
||||
const stockStatus = p.stock_status || p.payload?.stock_status || "unknown";
|
||||
const stockQty = p.stock_qty ?? p.stock_quantity ?? p.payload?.stock_quantity ?? null;
|
||||
const stockBadgeClass = stockStatus === "instock" ? "stock" : "nostock";
|
||||
const stockText = stockStatus === "instock" ? "En stock" : (stockStatus === "outofstock" ? "Sin stock" : stockStatus);
|
||||
const stockQtyText = stockQty !== null ? ` (${stockQty} disponibles)` : "";
|
||||
|
||||
const productName = this.escapeHtml(p.name || "—");
|
||||
const productSku = this.escapeHtml(p.sku || p.payload?.SKU || p.payload?.sku || p.slug || "—");
|
||||
|
||||
detail.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Nombre</label>
|
||||
<div class="field-value">${p.name || "—"}</div>
|
||||
<div class="field-value">${productName}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>ID WooCommerce</label>
|
||||
@@ -298,12 +370,18 @@ class ProductsCrud extends HTMLElement {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SKU</label>
|
||||
<div class="field-value">${p.sku || "—"}</div>
|
||||
<div class="field-value">${productSku}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Precio</label>
|
||||
<div class="field-value">${p.price != null ? `$${Number(p.price).toLocaleString()} ${p.currency || ""}` : "—"}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Stock</label>
|
||||
<div class="field-value">
|
||||
<span class="badge ${stockBadgeClass}" style="margin-left:0;">${stockText}</span>${stockQtyText}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Unidad de venta</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
@@ -317,15 +395,32 @@ class ProductsCrud extends HTMLElement {
|
||||
</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
|
||||
<label>Categorías actuales</label>
|
||||
<div id="currentCategories" style="display:flex;flex-wrap:wrap;gap:6px;min-height:30px;margin-bottom:8px;">
|
||||
${categoriesArray.length > 0
|
||||
? categoriesArray.map(cat => `
|
||||
<span class="category-tag" data-category="${this.escapeHtml(cat)}"
|
||||
style="display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;">
|
||||
${this.escapeHtml(cat)}
|
||||
<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>
|
||||
</span>
|
||||
`).join("")
|
||||
: '<span style="color:#8aa0b5;font-size:12px;">Sin categorías</span>'
|
||||
}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<option value="">-- Agregar categoría --</option>
|
||||
${this.getAllCategories().filter(c => !categoriesArray.includes(c)).map(cat =>
|
||||
`<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`
|
||||
).join("")}
|
||||
</select>
|
||||
<button id="addCatBtn" class="secondary" style="padding:8px 12px;">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Atributos</label>
|
||||
<div class="field-value">${attributes}</div>
|
||||
<div class="field-value">${this.escapeHtml(attributes)}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button id="saveProduct" style="width:100%;padding:10px;">Guardar cambios</button>
|
||||
@@ -336,12 +431,75 @@ class ProductsCrud extends HTMLElement {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Payload completo</label>
|
||||
<div class="field-value json">${JSON.stringify(p.payload || {}, null, 2)}</div>
|
||||
<div class="field-value json">${this.escapeHtml(JSON.stringify(p.payload || {}, null, 2))}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Bind save button
|
||||
this.shadowRoot.getElementById("saveProduct").onclick = () => this.saveProduct();
|
||||
|
||||
// Bind add category button
|
||||
this.shadowRoot.getElementById("addCatBtn").onclick = () => this.addCategoryToProduct();
|
||||
|
||||
// Bind remove category clicks
|
||||
this.shadowRoot.querySelectorAll(".remove-cat").forEach(el => {
|
||||
el.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const tag = el.closest(".category-tag");
|
||||
if (tag) tag.remove();
|
||||
// Actualizar el select para mostrar la categoría removida
|
||||
this.updateCategorySelect();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
addCategoryToProduct() {
|
||||
const select = this.shadowRoot.getElementById("addCategorySelect");
|
||||
const container = this.shadowRoot.getElementById("currentCategories");
|
||||
const categoryName = select.value;
|
||||
|
||||
if (!categoryName) return;
|
||||
|
||||
// Crear el tag
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "category-tag";
|
||||
tag.dataset.category = categoryName;
|
||||
tag.style = "display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;";
|
||||
tag.innerHTML = `${this.escapeHtml(categoryName)}<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>`;
|
||||
|
||||
// Bind remove
|
||||
tag.querySelector(".remove-cat").onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
tag.remove();
|
||||
this.updateCategorySelect();
|
||||
};
|
||||
|
||||
// Remover el mensaje "Sin categorías" si existe
|
||||
const emptyMsg = container.querySelector('span[style*="color:#8aa0b5"]');
|
||||
if (emptyMsg) emptyMsg.remove();
|
||||
|
||||
container.appendChild(tag);
|
||||
select.value = "";
|
||||
this.updateCategorySelect();
|
||||
}
|
||||
|
||||
updateCategorySelect() {
|
||||
const select = this.shadowRoot.getElementById("addCategorySelect");
|
||||
const currentTags = this.shadowRoot.querySelectorAll(".category-tag");
|
||||
const currentCategories = [...currentTags].map(t => t.dataset.category);
|
||||
const allCategories = this.getAllCategories();
|
||||
|
||||
// Rebuild options
|
||||
select.innerHTML = `<option value="">-- Agregar categoría --</option>` +
|
||||
allCategories
|
||||
.filter(c => !currentCategories.includes(c))
|
||||
.map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
getCurrentProductCategories() {
|
||||
const tags = this.shadowRoot.querySelectorAll(".category-tag");
|
||||
return [...tags].map(t => t.dataset.category);
|
||||
}
|
||||
|
||||
async saveProduct() {
|
||||
@@ -350,10 +508,9 @@ class ProductsCrud extends HTMLElement {
|
||||
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);
|
||||
const categories = this.getCurrentProductCategories();
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Guardando...";
|
||||
@@ -386,12 +543,19 @@ class ProductsCrud extends HTMLElement {
|
||||
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...` : "";
|
||||
|
||||
// Contar stock
|
||||
const inStockCount = this.selectedItems.filter(p => (p.stock_status || p.payload?.stock_status) === "instock").length;
|
||||
|
||||
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 style="font-size:11px;margin-top:4px;">
|
||||
<span class="badge stock" style="margin-left:0;">${inStockCount} en stock</span>
|
||||
<span class="badge nostock">${count - inStockCount} sin stock</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Unidad de venta (para todos)</label>
|
||||
@@ -400,18 +564,32 @@ class ProductsCrud extends HTMLElement {
|
||||
<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>
|
||||
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
Se aplicará a todos los productos seleccionados
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Agregar categoría (para todos)</label>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||
<option value="">-- Seleccionar categoría --</option>
|
||||
${this.getAllCategories().map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`).join("")}
|
||||
</select>
|
||||
<button id="addCategory" style="padding:8px 16px;">Agregar</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
||||
Se agregará esta categoría 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("addCategory").onclick = () => this.addCategoryToSelected();
|
||||
this.shadowRoot.getElementById("clearSelection").onclick = () => {
|
||||
this.selectedItems = [];
|
||||
this.lastClickedIndex = -1;
|
||||
@@ -419,6 +597,63 @@ class ProductsCrud extends HTMLElement {
|
||||
this.renderDetail();
|
||||
};
|
||||
}
|
||||
|
||||
async addCategoryToSelected() {
|
||||
if (!this.selectedItems.length) return;
|
||||
|
||||
const select = this.shadowRoot.getElementById("addCategorySelect");
|
||||
const btn = this.shadowRoot.getElementById("addCategory");
|
||||
const categoryName = select.value;
|
||||
|
||||
if (!categoryName) {
|
||||
alert("Seleccioná una categoría");
|
||||
return;
|
||||
}
|
||||
|
||||
const count = this.selectedItems.length;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Guardando...";
|
||||
|
||||
try {
|
||||
for (const p of this.selectedItems) {
|
||||
// Obtener categorías actuales
|
||||
let currentCats = [];
|
||||
try {
|
||||
if (p.categories) {
|
||||
const cats = typeof p.categories === 'string' ? JSON.parse(p.categories) : p.categories;
|
||||
currentCats = Array.isArray(cats) ? cats.map(c => c.name || c) : [String(cats)];
|
||||
} else if (p.payload?.categories) {
|
||||
currentCats = p.payload.categories.map(c => c.name || c);
|
||||
}
|
||||
} catch (e) { currentCats = []; }
|
||||
|
||||
// Agregar nueva categoría si no existe
|
||||
if (!currentCats.includes(categoryName)) {
|
||||
currentCats.push(categoryName);
|
||||
}
|
||||
|
||||
// Guardar
|
||||
await api.updateProduct(p.woo_product_id, {
|
||||
sell_unit: p.sell_unit || 'kg',
|
||||
categories: currentCats
|
||||
});
|
||||
|
||||
// Actualizar localmente
|
||||
p.categories = JSON.stringify(currentCats.map(name => ({ name })));
|
||||
const idx = this.items.findIndex(i => i.woo_product_id === p.woo_product_id);
|
||||
if (idx >= 0) this.items[idx].categories = p.categories;
|
||||
}
|
||||
|
||||
select.value = "";
|
||||
btn.textContent = `Agregado a ${count}!`;
|
||||
setTimeout(() => { btn.textContent = "Agregar"; btn.disabled = false; }, 1500);
|
||||
} catch (e) {
|
||||
console.error("Error adding category:", e);
|
||||
alert("Error agregando categoría: " + (e.message || e));
|
||||
btn.textContent = "Agregar";
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
inferUnit(p) {
|
||||
const name = String(p.name || "").toLowerCase();
|
||||
|
||||
340
public/components/quantities-crud.js
Normal file
340
public/components/quantities-crud.js
Normal file
@@ -0,0 +1,340 @@
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
const FIXED_EVENTS = [
|
||||
{ id: "asado", label: "Asado / Parrilla" },
|
||||
{ id: "horno", label: "Horno" },
|
||||
{ id: "sanguches", label: "Sanguches" },
|
||||
];
|
||||
|
||||
const PERSON_TYPES = [
|
||||
{ id: "adult", label: "Adulto" },
|
||||
{ id: "child", label: "Niño" },
|
||||
];
|
||||
|
||||
class QuantitiesCrud extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.products = [];
|
||||
this.ruleCounts = new Map();
|
||||
this.selectedProduct = null;
|
||||
this.productRules = [];
|
||||
this.loading = false;
|
||||
this.searchQuery = "";
|
||||
this.saving = false;
|
||||
|
||||
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 1fr; 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; }
|
||||
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||
button:hover { background:#1a5fd0; }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
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; display:flex; justify-content:space-between; align-items:center; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-name { font-weight:500; color:#e7eef7; }
|
||||
.item-price { font-size:12px; color:#8aa0b5; }
|
||||
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:#1f6feb; color:#fff; }
|
||||
.badge.empty { background:#253245; color:#8aa0b5; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.product-header { margin-bottom:16px; }
|
||||
.product-name { font-size:18px; font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
||||
.product-price { font-size:14px; color:#8aa0b5; }
|
||||
|
||||
.qty-grid { width:100%; border-collapse:collapse; }
|
||||
.qty-grid th { text-align:left; font-size:12px; color:#8aa0b5; padding:10px 8px; border-bottom:1px solid #253245; }
|
||||
.qty-grid td { padding:8px; border-bottom:1px solid #1e2a3a; }
|
||||
.qty-grid .event-label { font-size:13px; color:#e7eef7; font-weight:500; }
|
||||
.qty-grid input { width:70px; padding:6px 8px; font-size:12px; text-align:center; }
|
||||
.qty-grid select { width:70px; padding:6px 4px; font-size:11px; }
|
||||
|
||||
.cell-group { display:flex; gap:4px; align-items:center; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
|
||||
.status { font-size:12px; color:#2ecc71; margin-left:auto; }
|
||||
.status.error { color:#e74c3c; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Productos</div>
|
||||
<div class="toolbar">
|
||||
<input type="text" id="search" placeholder="Buscar producto..." />
|
||||
</div>
|
||||
<div class="list" id="list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">Cantidades por Persona</div>
|
||||
<div class="form" id="form">
|
||||
<div class="form-empty">Selecciona un producto para configurar cantidades</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot.getElementById("search").oninput = (e) => {
|
||||
this.searchQuery = e.target.value;
|
||||
this.renderList();
|
||||
};
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.renderList();
|
||||
|
||||
try {
|
||||
// Cargar productos y cantidades en paralelo
|
||||
const [productsData, quantitiesData] = await Promise.all([
|
||||
api.products({ limit: 2000 }),
|
||||
api.listQuantities(),
|
||||
]);
|
||||
|
||||
this.products = (productsData.items || []).filter(p => p.stock_status === "instock");
|
||||
|
||||
// Crear mapa de conteo de reglas
|
||||
this.ruleCounts = new Map();
|
||||
for (const c of (quantitiesData.counts || [])) {
|
||||
this.ruleCounts.set(c.woo_product_id, parseInt(c.rule_count, 10));
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
} catch (e) {
|
||||
console.error("Error loading:", e);
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
if (this.loading) {
|
||||
list.innerHTML = `<div class="loading">Cargando...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const q = this.searchQuery.toLowerCase().trim();
|
||||
let filtered = this.products;
|
||||
if (q) {
|
||||
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
if (!filtered.length) {
|
||||
list.innerHTML = `<div class="loading">No se encontraron productos</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ordenar: primero los que tienen reglas, luego por nombre
|
||||
filtered.sort((a, b) => {
|
||||
const countA = this.ruleCounts.get(a.woo_product_id) || 0;
|
||||
const countB = this.ruleCounts.get(b.woo_product_id) || 0;
|
||||
if (countA !== countB) return countB - countA;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const product of filtered.slice(0, 100)) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selectedProduct?.woo_product_id === product.woo_product_id ? " active" : "");
|
||||
|
||||
const ruleCount = this.ruleCounts.get(product.woo_product_id) || 0;
|
||||
|
||||
el.innerHTML = `
|
||||
<div>
|
||||
<div class="item-name">${product.name}</div>
|
||||
<div class="item-price">$${product.price || 0} / ${product.sell_unit || 'kg'}</div>
|
||||
</div>
|
||||
<span class="badge ${ruleCount === 0 ? 'empty' : ''}">${ruleCount}</span>
|
||||
`;
|
||||
|
||||
el.onclick = () => this.selectProduct(product);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async selectProduct(product) {
|
||||
this.selectedProduct = product;
|
||||
this.renderList();
|
||||
|
||||
// Cargar reglas del producto
|
||||
try {
|
||||
const data = await api.getProductQuantities(product.woo_product_id);
|
||||
this.productRules = data.rules || [];
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
console.error("Error loading product rules:", e);
|
||||
this.productRules = [];
|
||||
this.renderForm();
|
||||
}
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const form = this.shadowRoot.getElementById("form");
|
||||
|
||||
if (!this.selectedProduct) {
|
||||
form.innerHTML = `<div class="form-empty">Selecciona un producto para configurar cantidades</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const p = this.selectedProduct;
|
||||
|
||||
// Crear mapa de reglas existentes: "event_type:person_type" -> rule
|
||||
const ruleMap = new Map();
|
||||
for (const rule of this.productRules) {
|
||||
const key = `${rule.event_type}:${rule.person_type}`;
|
||||
ruleMap.set(key, rule);
|
||||
}
|
||||
|
||||
// Generar filas de la grilla
|
||||
const rows = FIXED_EVENTS.map(event => {
|
||||
const cells = PERSON_TYPES.map(person => {
|
||||
const key = `${event.id}:${person.id}`;
|
||||
const rule = ruleMap.get(key);
|
||||
const qty = rule?.qty_per_person ?? "";
|
||||
const unit = rule?.unit || "kg";
|
||||
|
||||
return `
|
||||
<td>
|
||||
<div class="cell-group">
|
||||
<input type="number"
|
||||
data-event="${event.id}"
|
||||
data-person="${person.id}"
|
||||
class="qty-input"
|
||||
value="${qty}"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="-" />
|
||||
<select data-event="${event.id}" data-person="${person.id}" class="unit-select">
|
||||
<option value="kg" ${unit === "kg" ? "selected" : ""}>kg</option>
|
||||
<option value="g" ${unit === "g" ? "selected" : ""}>g</option>
|
||||
<option value="unidad" ${unit === "unidad" ? "selected" : ""}>u</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="event-label">${event.label}</td>
|
||||
${cells}
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="product-header">
|
||||
<div class="product-name">${p.name}</div>
|
||||
<div class="product-price">$${p.price || 0} / ${p.sell_unit || 'kg'}</div>
|
||||
</div>
|
||||
|
||||
<table class="qty-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Evento</th>
|
||||
${PERSON_TYPES.map(pt => `<th>${pt.label}</th>`).join("")}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn" ${this.saving ? "disabled" : ""}>Guardar</button>
|
||||
<button id="clearBtn" class="secondary">Limpiar</button>
|
||||
<span class="status" id="status"></span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
|
||||
this.shadowRoot.getElementById("clearBtn").onclick = () => this.clear();
|
||||
}
|
||||
|
||||
async save() {
|
||||
if (!this.selectedProduct || this.saving) return;
|
||||
|
||||
// Recolectar valores de la grilla ANTES de renderizar
|
||||
const rules = [];
|
||||
const qtyInputs = this.shadowRoot.querySelectorAll(".qty-input");
|
||||
|
||||
for (const input of qtyInputs) {
|
||||
const eventType = input.dataset.event;
|
||||
const personType = input.dataset.person;
|
||||
const qty = parseFloat(input.value);
|
||||
|
||||
if (!isNaN(qty) && qty > 0) {
|
||||
const unitSelect = this.shadowRoot.querySelector(`.unit-select[data-event="${eventType}"][data-person="${personType}"]`);
|
||||
const unit = unitSelect?.value || "kg";
|
||||
|
||||
rules.push({
|
||||
event_type: eventType,
|
||||
person_type: personType,
|
||||
qty_per_person: qty,
|
||||
unit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.renderForm();
|
||||
|
||||
try {
|
||||
await api.saveProductQuantities(this.selectedProduct.woo_product_id, rules);
|
||||
|
||||
// Actualizar conteo local
|
||||
this.ruleCounts.set(this.selectedProduct.woo_product_id, rules.length);
|
||||
this.productRules = rules;
|
||||
|
||||
this.saving = false;
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
|
||||
const status = this.shadowRoot.getElementById("status");
|
||||
status.textContent = "Guardado";
|
||||
status.className = "status";
|
||||
setTimeout(() => { status.textContent = ""; }, 2000);
|
||||
} catch (e) {
|
||||
console.error("Error saving:", e);
|
||||
this.saving = false;
|
||||
this.renderForm();
|
||||
|
||||
const status = this.shadowRoot.getElementById("status");
|
||||
status.textContent = "Error al guardar";
|
||||
status.className = "status error";
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
const qtyInputs = this.shadowRoot.querySelectorAll(".qty-input");
|
||||
for (const input of qtyInputs) {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("quantities-crud", QuantitiesCrud);
|
||||
@@ -1,6 +1,10 @@
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
class RecommendationsCrud extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["rule-type"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
@@ -17,12 +21,22 @@ class RecommendationsCrud extends HTMLElement {
|
||||
// Productos seleccionados en el formulario
|
||||
this.selectedTriggerProducts = [];
|
||||
this.selectedRecommendedProducts = [];
|
||||
|
||||
// Items con qty/unit para reglas qty_per_person
|
||||
this.ruleItems = [];
|
||||
|
||||
// Tipo de regla filtrado por atributo (crosssell o qty_per_person)
|
||||
this.filterRuleType = this.getAttribute("rule-type") || null;
|
||||
|
||||
// Tipo de regla actual en el formulario
|
||||
this.currentRuleType = this.filterRuleType || "crosssell";
|
||||
this.currentTriggerEvent = "";
|
||||
|
||||
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 500px; gap:16px; height:100%; }
|
||||
.container { display:grid; grid-template-columns:1fr 1fr; 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; }
|
||||
|
||||
@@ -37,6 +51,7 @@ class RecommendationsCrud extends HTMLElement {
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.small { padding:4px 8px; font-size:11px; }
|
||||
|
||||
.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; }
|
||||
@@ -49,6 +64,7 @@ class RecommendationsCrud extends HTMLElement {
|
||||
.badge.active { background:#0f2a1a; color:#2ecc71; }
|
||||
.badge.inactive { background:#241214; color:#e74c3c; }
|
||||
.badge.priority { background:#253245; color:#8aa0b5; }
|
||||
.badge.type { background:#1a2a4a; color:#5dade2; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
@@ -95,11 +111,36 @@ class RecommendationsCrud extends HTMLElement {
|
||||
.product-chip .remove:hover { background:#c0392b; }
|
||||
|
||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
||||
|
||||
/* Items table styles */
|
||||
.items-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||
.items-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
|
||||
.items-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
|
||||
.items-table input { padding:6px 8px; font-size:12px; }
|
||||
.items-table input[type="number"] { width:70px; }
|
||||
.items-table select { padding:6px 8px; font-size:12px; width:80px; }
|
||||
.items-table .product-name { font-size:13px; color:#e7eef7; }
|
||||
.items-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
|
||||
|
||||
.add-item-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
||||
.add-item-row .field { margin-bottom:0; }
|
||||
|
||||
/* Rule type selector */
|
||||
.rule-type-selector { display:flex; gap:8px; margin-bottom:16px; }
|
||||
.rule-type-btn {
|
||||
flex:1; padding:12px; border:2px solid #253245; border-radius:8px;
|
||||
background:#0f1520; color:#8aa0b5; cursor:pointer; text-align:center;
|
||||
transition:all .15s;
|
||||
}
|
||||
.rule-type-btn:hover { border-color:#1f6feb; }
|
||||
.rule-type-btn.active { border-color:#1f6feb; background:#111b2a; color:#e7eef7; }
|
||||
.rule-type-btn .type-title { font-weight:600; margin-bottom:4px; }
|
||||
.rule-type-btn .type-desc { font-size:11px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Reglas de Recomendacion</div>
|
||||
<div class="panel-title" id="listTitle">Reglas</div>
|
||||
<div class="toolbar">
|
||||
<input type="text" id="search" placeholder="Buscar regla..." style="flex:1" />
|
||||
<button id="newBtn">+ Nueva</button>
|
||||
@@ -120,6 +161,20 @@ class RecommendationsCrud extends HTMLElement {
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Leer atributo rule-type
|
||||
this.filterRuleType = this.getAttribute("rule-type") || null;
|
||||
this.currentRuleType = this.filterRuleType || "crosssell";
|
||||
|
||||
// Actualizar título según el tipo
|
||||
const listTitle = this.shadowRoot.getElementById("listTitle");
|
||||
if (this.filterRuleType === "crosssell") {
|
||||
listTitle.textContent = "Reglas Cross-sell";
|
||||
} else if (this.filterRuleType === "qty_per_person") {
|
||||
listTitle.textContent = "Reglas de Cantidades";
|
||||
} else {
|
||||
listTitle.textContent = "Reglas de Recomendacion";
|
||||
}
|
||||
|
||||
this.shadowRoot.getElementById("search").oninput = (e) => {
|
||||
this.searchQuery = e.target.value;
|
||||
clearTimeout(this._searchTimer);
|
||||
@@ -132,6 +187,14 @@ class RecommendationsCrud extends HTMLElement {
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (name === "rule-type" && oldValue !== newValue) {
|
||||
this.filterRuleType = newValue;
|
||||
this.currentRuleType = newValue || "crosssell";
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
async loadProducts() {
|
||||
if (this.productsLoaded) return;
|
||||
try {
|
||||
@@ -150,7 +213,14 @@ class RecommendationsCrud extends HTMLElement {
|
||||
|
||||
try {
|
||||
const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
|
||||
this.items = data.items || [];
|
||||
let items = data.items || [];
|
||||
|
||||
// Filtrar por tipo si está especificado
|
||||
if (this.filterRuleType) {
|
||||
items = items.filter(item => (item.rule_type || "crosssell") === this.filterRuleType);
|
||||
}
|
||||
|
||||
this.items = items;
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
} catch (e) {
|
||||
@@ -166,6 +236,10 @@ class RecommendationsCrud extends HTMLElement {
|
||||
return p?.name || `Producto #${id}`;
|
||||
}
|
||||
|
||||
getProduct(id) {
|
||||
return this.allProducts.find(x => x.woo_product_id === id);
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
@@ -184,44 +258,79 @@ class RecommendationsCrud extends HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
|
||||
|
||||
// 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})` : "";
|
||||
const ruleType = item.rule_type || "crosssell";
|
||||
const triggerEvent = item.trigger_event || "";
|
||||
|
||||
// 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})` : "";
|
||||
let contentHtml = "";
|
||||
if (ruleType === "qty_per_person") {
|
||||
contentHtml = `
|
||||
<div class="item-trigger">Evento: ${triggerEvent || "General"}</div>
|
||||
<div class="item-queries">Cantidades por persona configuradas</div>
|
||||
`;
|
||||
} else {
|
||||
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})` : "";
|
||||
|
||||
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})` : "";
|
||||
|
||||
contentHtml = `
|
||||
<div class="item-trigger">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
|
||||
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Solo mostrar badge de tipo si no está filtrado
|
||||
const typeBadge = !this.filterRuleType
|
||||
? `<span class="badge type">${ruleType === "qty_per_person" ? "Cantidades" : "Cross-sell"}</span>`
|
||||
: "";
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-key">
|
||||
${item.rule_key}
|
||||
${typeBadge}
|
||||
<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">Cuando piden: ${triggerNames || "—"}${triggerMore}</div>
|
||||
<div class="item-queries">→ Recomendar: ${recoNames || "—"}${recoMore}</div>
|
||||
${contentHtml}
|
||||
`;
|
||||
|
||||
el.onclick = () => {
|
||||
this.selected = item;
|
||||
this.editMode = "edit";
|
||||
this.selectedTriggerProducts = [...(item.trigger_product_ids || [])];
|
||||
this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
};
|
||||
|
||||
el.onclick = () => this.selectItem(item);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async selectItem(item) {
|
||||
// Cargar detalles incluyendo items
|
||||
try {
|
||||
const detail = await api.getRecommendation(item.id);
|
||||
this.selected = detail || item;
|
||||
} catch (e) {
|
||||
this.selected = item;
|
||||
}
|
||||
|
||||
this.editMode = "edit";
|
||||
this.currentRuleType = this.selected.rule_type || "crosssell";
|
||||
this.currentTriggerEvent = this.selected.trigger_event || "";
|
||||
this.selectedTriggerProducts = [...(this.selected.trigger_product_ids || [])];
|
||||
this.selectedRecommendedProducts = [...(this.selected.recommended_product_ids || [])];
|
||||
this.ruleItems = [...(this.selected.items || [])];
|
||||
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
showCreateForm() {
|
||||
this.selected = null;
|
||||
this.editMode = "create";
|
||||
// Usar el tipo filtrado si está definido
|
||||
this.currentRuleType = this.filterRuleType || "crosssell";
|
||||
this.currentTriggerEvent = "";
|
||||
this.selectedTriggerProducts = [];
|
||||
this.selectedRecommendedProducts = [];
|
||||
this.ruleItems = [];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
@@ -243,10 +352,29 @@ class RecommendationsCrud extends HTMLElement {
|
||||
const active = this.selected?.active !== false;
|
||||
const priority = this.selected?.priority || 100;
|
||||
|
||||
// Solo mostrar selector de tipo si no está filtrado por atributo
|
||||
const showTypeSelector = !this.filterRuleType;
|
||||
|
||||
form.innerHTML = `
|
||||
${showTypeSelector ? `
|
||||
<div class="field">
|
||||
<label>Tipo de Regla</label>
|
||||
<div class="rule-type-selector">
|
||||
<div class="rule-type-btn ${this.currentRuleType === "crosssell" ? "active" : ""}" data-type="crosssell">
|
||||
<div class="type-title">Cross-sell</div>
|
||||
<div class="type-desc">Si pide A, ofrecer B, C, D</div>
|
||||
</div>
|
||||
<div class="rule-type-btn ${this.currentRuleType === "qty_per_person" ? "active" : ""}" data-type="qty_per_person">
|
||||
<div class="type-title">Cantidades</div>
|
||||
<div class="type-desc">Cantidad por persona por producto</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="field">
|
||||
<label>Nombre de la regla</label>
|
||||
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_recos, vinos_con_carne" />
|
||||
<input type="text" id="ruleKeyInput" value="${rule_key}" ${isCreate ? "" : "disabled"} placeholder="ej: asado_6_personas, vinos_con_carne" />
|
||||
<div class="field-hint">Identificador unico, sin espacios</div>
|
||||
</div>
|
||||
|
||||
@@ -264,24 +392,71 @@ class RecommendationsCrud extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<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 id="crosssellFields" style="display:${this.currentRuleType === "crosssell" ? "block" : "none"}">
|
||||
<div class="field">
|
||||
<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>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="field-hint">Productos que activan esta recomendacion</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<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 id="qtyFields" style="display:${this.currentRuleType === "qty_per_person" ? "block" : "none"}">
|
||||
<div class="field">
|
||||
<label>Tipo de Evento</label>
|
||||
<select id="triggerEventInput">
|
||||
<option value="" ${!this.currentTriggerEvent ? "selected" : ""}>General (cualquier evento)</option>
|
||||
<option value="asado" ${this.currentTriggerEvent === "asado" ? "selected" : ""}>Asado / Parrillada</option>
|
||||
<option value="horno" ${this.currentTriggerEvent === "horno" ? "selected" : ""}>Horno</option>
|
||||
<option value="cumple" ${this.currentTriggerEvent === "cumple" ? "selected" : ""}>Cumpleaños / Fiesta</option>
|
||||
<option value="almuerzo" ${this.currentTriggerEvent === "almuerzo" ? "selected" : ""}>Almuerzo / Cena</option>
|
||||
</select>
|
||||
<div class="field-hint">Evento que activa esta regla de cantidades</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Productos y Cantidades por Persona</label>
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th>Para</th>
|
||||
<th>Cantidad</th>
|
||||
<th>Unidad</th>
|
||||
<th>Razon</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="itemsTableBody">
|
||||
${this.renderItemsRows()}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="add-item-row">
|
||||
<div class="field" style="flex:2">
|
||||
<div class="product-selector" id="itemSelector">
|
||||
<input type="text" class="product-search" id="itemSearch" placeholder="Buscar producto para agregar..." />
|
||||
<div class="product-dropdown" id="itemDropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" style="flex:0">
|
||||
<button id="addItemBtn" class="secondary small">+ Agregar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint">Productos a sugerir al cliente</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -298,9 +473,177 @@ class RecommendationsCrud extends HTMLElement {
|
||||
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
|
||||
}
|
||||
|
||||
// Setup product selectors
|
||||
this.setupProductSelector("trigger", this.selectedTriggerProducts);
|
||||
this.setupProductSelector("reco", this.selectedRecommendedProducts);
|
||||
// Rule type selector
|
||||
this.shadowRoot.querySelectorAll(".rule-type-btn").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
this.currentRuleType = btn.dataset.type;
|
||||
this.renderForm();
|
||||
};
|
||||
});
|
||||
|
||||
// Setup product selectors for crosssell
|
||||
if (this.currentRuleType === "crosssell") {
|
||||
this.setupProductSelector("trigger", this.selectedTriggerProducts);
|
||||
this.setupProductSelector("reco", this.selectedRecommendedProducts);
|
||||
}
|
||||
|
||||
// Setup for qty_per_person
|
||||
if (this.currentRuleType === "qty_per_person") {
|
||||
this.setupItemsTable();
|
||||
this.setupAddItemSelector();
|
||||
}
|
||||
}
|
||||
|
||||
renderItemsRows() {
|
||||
if (!this.ruleItems.length) {
|
||||
return `<tr><td colspan="6" class="empty-hint">No hay productos configurados</td></tr>`;
|
||||
}
|
||||
|
||||
return this.ruleItems.map((item, idx) => {
|
||||
const product = this.getProduct(item.woo_product_id);
|
||||
const name = product?.name || `Producto #${item.woo_product_id}`;
|
||||
const audience = item.audience_type || "adult";
|
||||
return `
|
||||
<tr data-idx="${idx}">
|
||||
<td class="product-name">${name}</td>
|
||||
<td>
|
||||
<select class="item-audience">
|
||||
<option value="adult" ${audience === "adult" ? "selected" : ""}>Adulto</option>
|
||||
<option value="child" ${audience === "child" ? "selected" : ""}>Niño</option>
|
||||
<option value="all" ${audience === "all" ? "selected" : ""}>Todos</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="number" class="item-qty" value="${item.qty_per_person || ""}" step="0.01" min="0" placeholder="0.2" /></td>
|
||||
<td>
|
||||
<select class="item-unit">
|
||||
<option value="kg" ${item.unit === "kg" ? "selected" : ""}>kg</option>
|
||||
<option value="g" ${item.unit === "g" ? "selected" : ""}>g</option>
|
||||
<option value="unidad" ${item.unit === "unidad" ? "selected" : ""}>unidad</option>
|
||||
</select>
|
||||
</td>
|
||||
<td><input type="text" class="item-reason" value="${item.reason || ""}" placeholder="opcional" /></td>
|
||||
<td><button class="btn-remove small danger" data-idx="${idx}">×</button></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
setupItemsTable() {
|
||||
const tbody = this.shadowRoot.getElementById("itemsTableBody");
|
||||
if (!tbody) return;
|
||||
|
||||
// Handle audience changes
|
||||
tbody.querySelectorAll(".item-audience").forEach((select, idx) => {
|
||||
select.onchange = () => {
|
||||
this.ruleItems[idx].audience_type = select.value;
|
||||
};
|
||||
});
|
||||
|
||||
// Handle qty/unit/reason changes
|
||||
tbody.querySelectorAll(".item-qty").forEach((input, idx) => {
|
||||
input.onchange = () => {
|
||||
this.ruleItems[idx].qty_per_person = parseFloat(input.value) || null;
|
||||
};
|
||||
});
|
||||
|
||||
tbody.querySelectorAll(".item-unit").forEach((select, idx) => {
|
||||
select.onchange = () => {
|
||||
this.ruleItems[idx].unit = select.value;
|
||||
};
|
||||
});
|
||||
|
||||
tbody.querySelectorAll(".item-reason").forEach((input, idx) => {
|
||||
input.onchange = () => {
|
||||
this.ruleItems[idx].reason = input.value || null;
|
||||
};
|
||||
});
|
||||
|
||||
// Handle remove buttons
|
||||
tbody.querySelectorAll(".btn-remove").forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
this.ruleItems.splice(idx, 1);
|
||||
this.renderForm();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setupAddItemSelector() {
|
||||
const searchInput = this.shadowRoot.getElementById("itemSearch");
|
||||
const dropdown = this.shadowRoot.getElementById("itemDropdown");
|
||||
const addBtn = this.shadowRoot.getElementById("addItemBtn");
|
||||
|
||||
if (!searchInput || !dropdown) return;
|
||||
|
||||
let selectedProductId = null;
|
||||
|
||||
const renderDropdown = (query) => {
|
||||
const q = (query || "").toLowerCase().trim();
|
||||
const existingIds = new Set(this.ruleItems.map(i => i.woo_product_id));
|
||||
|
||||
let filtered = this.allProducts.filter(p => !existingIds.has(p.woo_product_id));
|
||||
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}">
|
||||
<span>${p.name}</span>
|
||||
<span class="price">$${p.price || 0}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
dropdown.querySelectorAll(".product-option").forEach(opt => {
|
||||
opt.onclick = () => {
|
||||
selectedProductId = parseInt(opt.dataset.id, 10);
|
||||
searchInput.value = this.getProductName(selectedProductId);
|
||||
dropdown.classList.remove("open");
|
||||
};
|
||||
});
|
||||
|
||||
dropdown.classList.add("open");
|
||||
};
|
||||
|
||||
searchInput.oninput = () => {
|
||||
selectedProductId = null;
|
||||
clearTimeout(this._itemTimer);
|
||||
this._itemTimer = setTimeout(() => renderDropdown(searchInput.value), 150);
|
||||
};
|
||||
|
||||
searchInput.onfocus = () => renderDropdown(searchInput.value);
|
||||
|
||||
addBtn.onclick = () => {
|
||||
if (!selectedProductId) {
|
||||
alert("Selecciona un producto primero");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ruleItems.push({
|
||||
woo_product_id: selectedProductId,
|
||||
audience_type: "adult",
|
||||
qty_per_person: null,
|
||||
unit: "kg",
|
||||
reason: null,
|
||||
display_order: this.ruleItems.length,
|
||||
});
|
||||
|
||||
searchInput.value = "";
|
||||
selectedProductId = null;
|
||||
this.renderForm();
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.shadowRoot.getElementById("itemSelector")?.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupProductSelector(type, selectedIds) {
|
||||
@@ -308,6 +651,8 @@ class RecommendationsCrud extends HTMLElement {
|
||||
const dropdown = this.shadowRoot.getElementById(`${type}Dropdown`);
|
||||
const selectedContainer = this.shadowRoot.getElementById(`${type}Selected`);
|
||||
|
||||
if (!searchInput || !dropdown || !selectedContainer) return;
|
||||
|
||||
const renderSelected = () => {
|
||||
const ids = type === "trigger" ? this.selectedTriggerProducts : this.selectedRecommendedProducts;
|
||||
if (!ids.length) {
|
||||
@@ -342,7 +687,7 @@ class RecommendationsCrud extends HTMLElement {
|
||||
if (q) {
|
||||
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
|
||||
}
|
||||
filtered = filtered.slice(0, 50); // Limit for performance
|
||||
filtered = filtered.slice(0, 50);
|
||||
|
||||
if (!q && !filtered.length) {
|
||||
dropdown.classList.remove("open");
|
||||
@@ -391,7 +736,6 @@ class RecommendationsCrud extends HTMLElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
@@ -411,27 +755,51 @@ class RecommendationsCrud extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
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: {}, // Legacy field, keep empty
|
||||
queries: [], // Legacy field, keep empty
|
||||
trigger: {},
|
||||
queries: [],
|
||||
ask_slots: [],
|
||||
active,
|
||||
priority,
|
||||
trigger_product_ids: this.selectedTriggerProducts,
|
||||
recommended_product_ids: this.selectedRecommendedProducts,
|
||||
rule_type: this.currentRuleType,
|
||||
trigger_event: null,
|
||||
trigger_product_ids: [],
|
||||
recommended_product_ids: [],
|
||||
items: [],
|
||||
};
|
||||
|
||||
if (this.currentRuleType === "crosssell") {
|
||||
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;
|
||||
}
|
||||
data.trigger_product_ids = this.selectedTriggerProducts;
|
||||
data.recommended_product_ids = this.selectedRecommendedProducts;
|
||||
} else {
|
||||
// qty_per_person
|
||||
const triggerEvent = this.shadowRoot.getElementById("triggerEventInput")?.value || null;
|
||||
data.trigger_event = triggerEvent;
|
||||
|
||||
if (!this.ruleItems.length) {
|
||||
alert("Agrega al menos un producto con cantidad");
|
||||
return;
|
||||
}
|
||||
|
||||
data.items = this.ruleItems.map((item, idx) => ({
|
||||
woo_product_id: item.woo_product_id,
|
||||
audience_type: item.audience_type || "adult",
|
||||
qty_per_person: item.qty_per_person,
|
||||
unit: item.unit || "kg",
|
||||
reason: item.reason || null,
|
||||
display_order: idx,
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.editMode === "create") {
|
||||
await api.createRecommendation(data);
|
||||
@@ -469,6 +837,7 @@ class RecommendationsCrud extends HTMLElement {
|
||||
this.selected = null;
|
||||
this.selectedTriggerProducts = [];
|
||||
this.selectedRecommendedProducts = [];
|
||||
this.ruleItems = [];
|
||||
this.renderList();
|
||||
this.renderForm();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class UsersCrud extends HTMLElement {
|
||||
<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 1fr; 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; }
|
||||
|
||||
|
||||
@@ -137,6 +137,11 @@ export const api = {
|
||||
return fetch(u).then(r => r.json());
|
||||
},
|
||||
|
||||
async getRecommendation(id) {
|
||||
if (!id) return null;
|
||||
return fetch(`/recommendations/${encodeURIComponent(id)}`).then(r => r.json());
|
||||
},
|
||||
|
||||
async createRecommendation(data) {
|
||||
return fetch("/recommendations", {
|
||||
method: "POST",
|
||||
@@ -156,4 +161,22 @@ export const api = {
|
||||
async deleteRecommendation(id) {
|
||||
return fetch(`/recommendations/${encodeURIComponent(id)}`, { method: "DELETE" }).then(r => r.json());
|
||||
},
|
||||
|
||||
// Quantities (cantidades por producto/evento/persona)
|
||||
async listQuantities() {
|
||||
return fetch("/quantities").then(r => r.json());
|
||||
},
|
||||
|
||||
async getProductQuantities(wooProductId) {
|
||||
if (!wooProductId) return { rules: [] };
|
||||
return fetch(`/quantities/${encodeURIComponent(wooProductId)}`).then(r => r.json());
|
||||
},
|
||||
|
||||
async saveProductQuantities(wooProductId, rules) {
|
||||
return fetch(`/quantities/${encodeURIComponent(wooProductId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rules }),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user