Files
botino/public/components/aliases-crud.js
2026-01-18 18:28:28 -03:00

475 lines
17 KiB
JavaScript
Raw 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";
class AliasesCrud extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.items = [];
this.products = [];
this.selected = null;
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 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; }
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-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
.item-products { font-size:12px; color:#8aa0b5; }
.item-boost { color:#2ecc71; font-size:11px; }
.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; }
.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">
<div class="panel">
<div class="panel-title">Equivalencias (Aliases)</div>
<div class="toolbar">
<input type="text" id="search" placeholder="Buscar alias..." style="flex:1" />
<button id="newBtn">+ Nuevo</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 un alias o crea uno nuevo</div>
</div>
</div>
</div>
`;
}
connectedCallback() {
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();
this.load();
this.loadProducts();
}
async load() {
this.loading = true;
this.renderList();
try {
const data = await api.aliases({ q: this.searchQuery, limit: 500 });
this.items = data.items || [];
this.loading = false;
this.renderList();
} catch (e) {
console.error("Error loading aliases:", e);
this.items = [];
this.loading = false;
this.renderList();
}
}
async loadProducts() {
try {
const data = await api.products({ limit: 2000 });
this.products = data.items || [];
} catch (e) {
console.error("Error loading products:", e);
this.products = [];
}
}
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");
if (this.loading) {
list.innerHTML = `<div class="loading">Cargando...</div>`;
return;
}
if (!this.items.length) {
list.innerHTML = `<div class="loading">No se encontraron aliases</div>`;
return;
}
list.innerHTML = "";
for (const item of this.items) {
const el = document.createElement("div");
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
// 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}" <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();
};
list.appendChild(el);
}
}
showCreateForm() {
this.selected = null;
this.editMode = "create";
this.productMappings = [];
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 un alias o crea uno nuevo</div>`;
return;
}
const isCreate = this.editMode === "create";
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
const alias = this.selected?.alias || "";
const categoryHint = this.selected?.category_hint || "";
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>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>` : ""}
<button id="cancelBtn" class="secondary">Cancelar</button>
</div>
`;
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
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 categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
if (!aliasInput) {
alert("El alias es requerido");
return;
}
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: primaryProduct.woo_product_id,
boost: primaryProduct.score || 1.0,
category_hint: categoryInput || null,
product_mappings: this.productMappings,
};
try {
if (this.editMode === "create") {
await api.createAlias(data);
} else {
await api.updateAlias(this.selected.alias, data);
}
this.editMode = null;
this.selected = null;
this.productMappings = [];
await this.load();
this.renderForm();
} catch (e) {
console.error("Error saving alias:", e);
alert("Error guardando: " + (e.message || e));
}
}
async delete() {
if (!this.selected?.alias) return;
if (!confirm(`¿Eliminar el alias "${this.selected.alias}"?`)) return;
try {
await api.deleteAlias(this.selected.alias);
this.editMode = null;
this.selected = null;
this.productMappings = [];
await this.load();
this.renderForm();
} catch (e) {
console.error("Error deleting alias:", e);
alert("Error eliminando: " + (e.message || e));
}
}
cancel() {
this.editMode = null;
this.selected = null;
this.productMappings = [];
this.renderList();
this.renderForm();
}
}
customElements.define("aliases-crud", AliasesCrud);