productos, equivalencias, cross-sell y cantidades
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user