productos, equivalencias, cross-sell y cantidades

This commit is contained in:
Lucas Tettamanti
2026-01-18 18:28:28 -03:00
parent 8cc4744c49
commit c7c56ddbfc
32 changed files with 4083 additions and 2073 deletions

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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;">&times;</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;">&times;</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();