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