Files
botino/public/components/recommendations-crud.js
2026-01-25 22:32:58 -03:00

890 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api } from "../lib/api.js";
import { on } from "../lib/bus.js";
import { navigateToItem } from "../lib/router.js";
import { modal } from "../lib/modal.js";
class RecommendationsCrud extends HTMLElement {
static get observedAttributes() {
return ["rule-type"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.selected = null;
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 = [];
// 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 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, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
textarea { min-height:60px; resize:vertical; font-size:13px; }
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; }
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-key { font-weight:600; color:#e7eef7; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.item-trigger { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
.item-queries { font-size:11px; color:#2ecc71; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; }
.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; }
.field { margin-bottom:16px; }
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
.field-row { display:flex; gap:12px; }
.field-row .field { flex:1; }
.actions { display:flex; gap:8px; margin-top:16px; }
.loading { text-align:center; padding:40px; color:#8aa0b5; }
.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; }
/* 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" id="listTitle">Reglas</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar regla..." style="flex:1" />
<button id="newBtn">+ Nueva</button>
</div>
<div class="list" id="list">
<div class="loading">Cargando...</div>
</div>
</div>
<div class="panel">
<div class="panel-title" id="formTitle">Detalle</div>
<div class="form" id="form">
<div class="form-empty">Selecciona una regla o crea una nueva</div>
</div>
</div>
</div>
`;
}
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);
this._searchTimer = setTimeout(() => this.load(), 300);
};
this.shadowRoot.getElementById("newBtn").onclick = () => this.showCreateForm();
// Escuchar cambios de ruta para deep-linking
this._unsubRouter = on("router:viewChanged", ({ view, params }) => {
if (view === "crosssell" && params.id) {
this.selectItemById(params.id);
}
});
this.load();
this.loadProducts();
}
disconnectedCallback() {
this._unsubRouter?.();
}
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 {
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() {
this.loading = true;
this.renderList();
try {
const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
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();
// Si hay un item pendiente de selección (deep-link), seleccionarlo
if (this._pendingItemId) {
const item = this.items.find(i => i.id === this._pendingItemId);
if (item) {
this.selectItem(item, { updateUrl: false });
}
this._pendingItemId = null;
}
} catch (e) {
console.error("Error loading recommendations:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
getProductName(id) {
const p = this.allProducts.find(x => x.woo_product_id === id);
return p?.name || `Producto #${id}`;
}
getProduct(id) {
return this.allProducts.find(x => x.woo_product_id === id);
}
renderList() {
const list = this.shadowRoot.getElementById("list");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron reglas</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
const ruleType = item.rule_type || "crosssell";
const triggerEvent = item.trigger_event || "";
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>
${contentHtml}
`;
el.onclick = () => this.selectItem(item);
list.appendChild(el);
}
}
async selectItem(item, { updateUrl = true } = {}) {
// 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();
// Actualizar URL
if (updateUrl && this.selected) {
navigateToItem("crosssell", this.selected.id);
}
}
selectItemById(ruleId) {
const id = parseInt(ruleId);
if (!id) return;
// Buscar en los items cargados
const item = this.items.find(i => i.id === id);
if (item) {
this.selectItem(item, { updateUrl: false });
} else {
// Guardar el ID pendiente para seleccionar después de cargar
this._pendingItemId = id;
}
}
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();
}
renderForm() {
const form = this.shadowRoot.getElementById("form");
const title = this.shadowRoot.getElementById("formTitle");
if (!this.editMode) {
title.textContent = "Detalle";
form.innerHTML = `<div class="form-empty">Selecciona una regla o crea una nueva</div>`;
return;
}
const isCreate = this.editMode === "create";
title.textContent = isCreate ? "Nueva Regla" : "Editar Regla";
const rule_key = this.selected?.rule_key || "";
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_6_personas, 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>
<div class="field">
<label>Estado</label>
<label class="toggle">
<input type="checkbox" id="activeInput" ${active ? "checked" : ""} />
<span>Activa</span>
</label>
</div>
</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>
<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>
<div class="actions">
<button id="saveBtn">${isCreate ? "Crear" : "Guardar"}</button>
${!isCreate ? `<button id="deleteBtn" class="danger">Eliminar</button>` : ""}
<button id="cancelBtn" class="secondary">Cancelar</button>
</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();
}
// 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) {
modal.warn("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) {
const searchInput = this.shadowRoot.getElementById(`${type}Search`);
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) {
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);
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);
}
};
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;
if (!ruleKey) {
modal.warn("El nombre de la regla es requerido");
return;
}
const data = {
rule_key: ruleKey,
trigger: {},
queries: [],
ask_slots: [],
active,
priority,
rule_type: this.currentRuleType,
trigger_event: null,
trigger_product_ids: [],
recommended_product_ids: [],
items: [],
};
if (this.currentRuleType === "crosssell") {
if (!this.selectedTriggerProducts.length) {
modal.warn("Selecciona al menos un producto trigger");
return;
}
if (!this.selectedRecommendedProducts.length) {
modal.warn("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) {
modal.warn("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);
} else {
await api.updateRecommendation(this.selected.id, data);
}
this.editMode = null;
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error saving recommendation:", e);
modal.error("Error guardando: " + (e.message || e));
}
}
async delete() {
if (!this.selected?.id) return;
const confirmed = await modal.confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`);
if (!confirmed) return;
try {
await api.deleteRecommendation(this.selected.id);
this.editMode = null;
this.selected = null;
await this.load();
this.renderForm();
} catch (e) {
console.error("Error deleting recommendation:", e);
modal.error("Error eliminando: " + (e.message || e));
}
}
cancel() {
this.editMode = null;
this.selected = null;
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
this.ruleItems = [];
this.renderList();
this.renderForm();
}
}
customElements.define("recommendations-crud", RecommendationsCrud);