diff --git a/db/migrations/20260119100000_reco_rule_items.sql b/db/migrations/20260119100000_reco_rule_items.sql
new file mode 100644
index 0000000..5d8fea4
--- /dev/null
+++ b/db/migrations/20260119100000_reco_rule_items.sql
@@ -0,0 +1,39 @@
+-- migrate:up
+
+-- Nueva tabla para items de reglas de recomendación con qty/unit
+create table if not exists reco_rule_items (
+ id bigserial primary key,
+ rule_id bigint not null references product_reco_rules(id) on delete cascade,
+ woo_product_id integer not null,
+ qty_per_person numeric(6,3), -- ej: 0.200 = 200g por persona
+ unit text, -- kg | g | unidad
+ reason text, -- razón opcional (ej: "base del asado")
+ display_order integer not null default 0,
+ unique (rule_id, woo_product_id)
+);
+
+create index if not exists reco_rule_items_rule_idx on reco_rule_items(rule_id);
+
+-- Agregar tipo de regla y evento trigger a product_reco_rules
+alter table product_reco_rules
+ add column if not exists rule_type text not null default 'crosssell',
+ add column if not exists trigger_event text;
+
+-- Índice para búsqueda por tipo y evento
+create index if not exists product_reco_rules_type_event_idx
+ on product_reco_rules(tenant_id, rule_type, trigger_event)
+ where active = true;
+
+-- Migrar datos existentes: copiar recommended_product_ids a reco_rule_items
+insert into reco_rule_items (rule_id, woo_product_id, display_order)
+select r.id, unnest(r.recommended_product_ids), row_number() over (partition by r.id)
+from product_reco_rules r
+where array_length(r.recommended_product_ids, 1) > 0
+on conflict (rule_id, woo_product_id) do nothing;
+
+-- migrate:down
+drop index if exists product_reco_rules_type_event_idx;
+alter table product_reco_rules
+ drop column if exists rule_type,
+ drop column if exists trigger_event;
+drop table if exists reco_rule_items;
diff --git a/db/migrations/20260119100100_alias_product_mappings.sql b/db/migrations/20260119100100_alias_product_mappings.sql
new file mode 100644
index 0000000..41bb4b2
--- /dev/null
+++ b/db/migrations/20260119100100_alias_product_mappings.sql
@@ -0,0 +1,24 @@
+-- migrate:up
+
+-- Nueva tabla para mapeos alias -> múltiples productos con score
+create table if not exists alias_product_mappings (
+ tenant_id uuid not null references tenants(id) on delete cascade,
+ alias text not null,
+ woo_product_id integer not null,
+ score numeric(4,2) not null default 1.0,
+ created_at timestamptz not null default now(),
+ primary key (tenant_id, alias, woo_product_id)
+);
+
+create index if not exists alias_product_mappings_alias_idx
+ on alias_product_mappings(tenant_id, alias);
+
+-- Migrar datos existentes: copiar woo_product_id de product_aliases
+insert into alias_product_mappings (tenant_id, alias, woo_product_id, score)
+select tenant_id, alias, woo_product_id, coalesce(boost, 1.0)
+from product_aliases
+where woo_product_id is not null
+on conflict (tenant_id, alias, woo_product_id) do nothing;
+
+-- migrate:down
+drop table if exists alias_product_mappings;
diff --git a/db/migrations/20260119110000_reco_rule_items_audience.sql b/db/migrations/20260119110000_reco_rule_items_audience.sql
new file mode 100644
index 0000000..1a9415b
--- /dev/null
+++ b/db/migrations/20260119110000_reco_rule_items_audience.sql
@@ -0,0 +1,12 @@
+-- migrate:up
+
+-- Agregar campo para distinguir adulto/niño
+alter table reco_rule_items
+ add column if not exists audience_type text not null default 'adult';
+
+-- Valores válidos: 'adult', 'child', 'all'
+comment on column reco_rule_items.audience_type is 'Tipo de audiencia: adult, child, all';
+
+-- migrate:down
+alter table reco_rule_items
+ drop column if exists audience_type;
diff --git a/db/migrations/20260119120000_product_qty_rules.sql b/db/migrations/20260119120000_product_qty_rules.sql
new file mode 100644
index 0000000..d8c23fb
--- /dev/null
+++ b/db/migrations/20260119120000_product_qty_rules.sql
@@ -0,0 +1,41 @@
+-- migrate:up
+
+-- Nueva tabla simplificada para cantidades por producto/evento/persona
+create table if not exists product_qty_rules (
+ id bigserial primary key,
+ tenant_id uuid not null references tenants(id) on delete cascade,
+ woo_product_id integer not null,
+ event_type text not null, -- 'asado' | 'horno' | 'cumple' | 'almuerzo'
+ person_type text not null, -- 'adult' | 'child'
+ qty_per_person numeric(6,3), -- cantidad por persona
+ unit text not null default 'kg', -- 'kg' | 'g' | 'unidad'
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (tenant_id, woo_product_id, event_type, person_type)
+);
+
+create index if not exists product_qty_rules_tenant_idx on product_qty_rules(tenant_id);
+create index if not exists product_qty_rules_product_idx on product_qty_rules(tenant_id, woo_product_id);
+create index if not exists product_qty_rules_event_idx on product_qty_rules(tenant_id, event_type);
+
+-- Migrar datos existentes de reco_rule_items (donde la regla es tipo qty_per_person)
+insert into product_qty_rules (tenant_id, woo_product_id, event_type, person_type, qty_per_person, unit)
+select
+ r.tenant_id,
+ i.woo_product_id,
+ coalesce(r.trigger_event, 'asado') as event_type,
+ coalesce(i.audience_type, 'adult') as person_type,
+ i.qty_per_person,
+ coalesce(i.unit, 'kg') as unit
+from reco_rule_items i
+inner join product_reco_rules r on r.id = i.rule_id
+where r.rule_type = 'qty_per_person'
+ and i.qty_per_person is not null
+on conflict (tenant_id, woo_product_id, event_type, person_type)
+do update set
+ qty_per_person = excluded.qty_per_person,
+ unit = excluded.unit,
+ updated_at = now();
+
+-- migrate:down
+drop table if exists product_qty_rules;
diff --git a/public/app.js b/public/app.js
index 6af9df8..2dc6958 100644
--- a/public/app.js
+++ b/public/app.js
@@ -7,6 +7,7 @@ import "./components/users-crud.js";
import "./components/products-crud.js";
import "./components/aliases-crud.js";
import "./components/recommendations-crud.js";
+import "./components/quantities-crud.js";
import { connectSSE } from "./lib/sse.js";
connectSSE();
diff --git a/public/components/aliases-crud.js b/public/components/aliases-crud.js
index 0e753de..8b1c054 100644
--- a/public/components/aliases-crud.js
+++ b/public/components/aliases-crud.js
@@ -10,12 +10,15 @@ class AliasesCrud extends HTMLElement {
this.loading = false;
this.searchQuery = "";
this.editMode = null; // 'create' | 'edit' | null
+
+ // Productos mapeados con scores
+ this.productMappings = [];
this.shadowRoot.innerHTML = `
@@ -63,7 +96,7 @@ class AliasesCrud extends HTMLElement {
@@ -110,6 +143,11 @@ class AliasesCrud extends HTMLElement {
}
}
+ 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");
@@ -128,18 +166,40 @@ class AliasesCrud extends HTMLElement {
const el = document.createElement("div");
el.className = "item" + (this.selected?.alias === item.alias ? " active" : "");
- const product = this.products.find(p => p.woo_product_id === item.woo_product_id);
- const productName = product?.name || `ID: ${item.woo_product_id || "—"}`;
- const boost = item.boost ? `+${item.boost}` : "";
+ // 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 = `→ ${productNames}${more}
`;
+ } else if (item.woo_product_id) {
+ // Fallback al producto único legacy
+ const productName = this.getProductName(item.woo_product_id);
+ const boost = item.boost ? ` (boost: +${item.boost}) ` : "";
+ productsHtml = `→ ${productName}${boost}
`;
+ } else {
+ productsHtml = `→ Sin productos
`;
+ }
el.innerHTML = `
- "${item.alias}"
- → ${productName} ${boost ? `(boost: ${boost}) ` : ""}
+ "${item.alias}" ${mappings.length || (item.woo_product_id ? 1 : 0)} productos
+ ${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();
};
@@ -151,6 +211,7 @@ class AliasesCrud extends HTMLElement {
showCreateForm() {
this.selected = null;
this.editMode = "create";
+ this.productMappings = [];
this.renderList();
this.renderForm();
}
@@ -161,7 +222,7 @@ class AliasesCrud extends HTMLElement {
if (!this.editMode) {
title.textContent = "Detalle";
- form.innerHTML = `Seleccioná un alias o creá uno nuevo
`;
+ form.innerHTML = `Selecciona un alias o crea uno nuevo
`;
return;
}
@@ -169,36 +230,49 @@ class AliasesCrud extends HTMLElement {
title.textContent = isCreate ? "Nuevo Alias" : "Editar Alias";
const alias = this.selected?.alias || "";
- const wooProductId = this.selected?.woo_product_id || "";
- const boost = this.selected?.boost || 0;
const categoryHint = this.selected?.category_hint || "";
- const productOptions = this.products.map(p =>
- `${p.name} `
- ).join("");
-
form.innerHTML = `
-
- Producto destino
-
- — Seleccionar producto —
- ${productOptions}
-
-
-
-
Boost (puntuacion extra)
-
-
Valor entre 0 y 10. Mayor boost = mayor prioridad en resultados
-
+
Categoria hint (opcional)
+
+
+
Productos que matchean
+
+
+
+ Producto
+ Score (0-1)
+
+
+
+
+ ${this.renderMappingsRows()}
+
+
+
+
+
Mayor score = mayor prioridad. Si hay ambiguedad, se pregunta al usuario.
+
+
${isCreate ? "Crear" : "Guardar"}
${!isCreate ? `
Eliminar ` : ""}
@@ -211,28 +285,147 @@ class AliasesCrud extends HTMLElement {
if (!isCreate) {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
+
+ this.setupMappingsTable();
+ this.setupAddMappingSelector();
+ }
+
+ renderMappingsRows() {
+ if (!this.productMappings.length) {
+ return `
No hay productos mapeados `;
+ }
+
+ return this.productMappings.map((mapping, idx) => {
+ const name = this.getProductName(mapping.woo_product_id);
+ return `
+
+ ${name}
+
+ ×
+
+ `;
+ }).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 => `
+
+ ${p.name}
+ $${p.price || 0}
+
+ `).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 productInput = this.shadowRoot.getElementById("productInput").value;
- const boostInput = parseFloat(this.shadowRoot.getElementById("boostInput").value) || 0;
const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim();
if (!aliasInput) {
alert("El alias es requerido");
return;
}
- if (!productInput) {
- alert("Seleccioná un producto");
+
+ 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: parseInt(productInput, 10),
- boost: boostInput,
+ woo_product_id: primaryProduct.woo_product_id,
+ boost: primaryProduct.score || 1.0,
category_hint: categoryInput || null,
+ product_mappings: this.productMappings,
};
try {
@@ -243,6 +436,7 @@ class AliasesCrud extends HTMLElement {
}
this.editMode = null;
this.selected = null;
+ this.productMappings = [];
await this.load();
this.renderForm();
} catch (e) {
@@ -259,6 +453,7 @@ class AliasesCrud extends HTMLElement {
await api.deleteAlias(this.selected.alias);
this.editMode = null;
this.selected = null;
+ this.productMappings = [];
await this.load();
this.renderForm();
} catch (e) {
@@ -270,6 +465,7 @@ class AliasesCrud extends HTMLElement {
cancel() {
this.editMode = null;
this.selected = null;
+ this.productMappings = [];
this.renderList();
this.renderForm();
}
diff --git a/public/components/conversation-inspector.js b/public/components/conversation-inspector.js
index 1a14dba..3f11e33 100644
--- a/public/components/conversation-inspector.js
+++ b/public/components/conversation-inspector.js
@@ -170,14 +170,50 @@ class ConversationInspector extends HTMLElement {
if (!list.length) return "—";
return list
.map((it) => {
- const label = it.label || it.name || `#${it.product_id}`;
- const qty = it.quantity != null ? `${it.quantity}` : "?";
+ const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
+ const qty = it.quantity ?? it.qty ?? "?";
const unit = it.unit || "";
return `${label} (${qty}${unit ? " " + unit : ""})`;
})
.join(" · ");
}
+ formatOrder(cart, pending, order) {
+ const parts = [];
+
+ // Cart items
+ const cartList = Array.isArray(cart) ? cart : [];
+ if (cartList.length > 0) {
+ const cartStr = cartList
+ .map((it) => {
+ const label = it.label || it.name || `#${it.product_id || it.woo_id}`;
+ const qty = it.quantity ?? it.qty ?? "?";
+ const unit = it.unit || "";
+ return `${label} (${qty}${unit ? " " + unit : ""})`;
+ })
+ .join(" · ");
+ parts.push(cartStr);
+ }
+
+ // Pending items
+ const pendingList = Array.isArray(pending) ? pending : [];
+ const activePending = pendingList.filter(p => p.status !== "READY");
+ if (activePending.length > 0) {
+ parts.push(`[${activePending.length} pendiente(s)]`);
+ }
+
+ // Checkout info
+ const checkoutInfo = [];
+ if (order?.is_delivery === true) checkoutInfo.push("🚚");
+ if (order?.is_delivery === false) checkoutInfo.push("🏪");
+ if (order?.payment_type === "cash") checkoutInfo.push("💵");
+ if (order?.payment_type === "link") checkoutInfo.push("💳");
+ if (order?.is_paid) checkoutInfo.push("✅");
+ if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
+
+ return parts.length ? parts.join(" ") : "—";
+ }
+
toolSummary(tools = []) {
return tools.map((t) => ({
type: t.type || t.name || "tool",
@@ -218,7 +254,10 @@ class ConversationInspector extends HTMLElement {
const intent = run?.llm_output?.intent || "—";
const nextState = run?.llm_output?.next_state || "—";
const prevState = row.nextRun?.prev_state || "—";
- const basket = run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
+ // Usar orden nueva si está disponible, sino fallback a formato viejo
+ const order = run?.order || {};
+ const basket = order.cart || run?.llm_output?.full_basket?.items || run?.llm_output?.basket_resolved?.items || [];
+ const pendingItems = order.pending || [];
const tools = this.toolSummary(run?.tools || []);
const llmMeta = run?.llm_output?._llm || null;
@@ -240,7 +279,7 @@ class ConversationInspector extends HTMLElement {
NLU
${dir === "out" && llmMeta ? llmNote : "—"}
- Carrito: ${dir === "out" ? this.formatCart(basket) : "—"}
+ Carrito: ${dir === "out" ? this.formatOrder(basket, pendingItems, order) : "—"}
${tools
.map(
diff --git a/public/components/conversations-crud.js b/public/components/conversations-crud.js
index a4499b2..77d05e5 100644
--- a/public/components/conversations-crud.js
+++ b/public/components/conversations-crud.js
@@ -16,7 +16,7 @@ class ConversationsCrud extends HTMLElement {
+
+
+
+
+
+
Cantidades por Persona
+
+
+
+ `;
+ }
+
+ 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 = `
Cargando...
`;
+ 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 = `
No se encontraron productos
`;
+ 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 = `
+
+
${product.name}
+
$${product.price || 0} / ${product.sell_unit || 'kg'}
+
+
${ruleCount}
+ `;
+
+ 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 = `
Selecciona un producto para configurar cantidades
`;
+ 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 `
+
+
+
+
+ kg
+ g
+ u
+
+
+
+ `;
+ }).join("");
+
+ return `
+
+ ${event.label}
+ ${cells}
+
+ `;
+ }).join("");
+
+ form.innerHTML = `
+
+
+
+
+
+ Evento
+ ${PERSON_TYPES.map(pt => `${pt.label} `).join("")}
+
+
+
+ ${rows}
+
+
+
+
+ Guardar
+ Limpiar
+
+
+ `;
+
+ 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);
diff --git a/public/components/recommendations-crud.js b/public/components/recommendations-crud.js
index aea69e9..faef555 100644
--- a/public/components/recommendations-crud.js
+++ b/public/components/recommendations-crud.js
@@ -1,6 +1,10 @@
import { api } from "../lib/api.js";
class RecommendationsCrud extends HTMLElement {
+ static get observedAttributes() {
+ return ["rule-type"];
+ }
+
constructor() {
super();
this.attachShadow({ mode: "open" });
@@ -17,12 +21,22 @@ class RecommendationsCrud extends HTMLElement {
// 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 = `
-
Reglas de Recomendacion
+
Reglas
+ Nueva
@@ -120,6 +161,20 @@ class RecommendationsCrud extends HTMLElement {
}
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);
@@ -132,6 +187,14 @@ class RecommendationsCrud extends HTMLElement {
this.loadProducts();
}
+ 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 {
@@ -150,7 +213,14 @@ class RecommendationsCrud extends HTMLElement {
try {
const data = await api.recommendations({ q: this.searchQuery, limit: 200 });
- this.items = data.items || [];
+ 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();
} catch (e) {
@@ -166,6 +236,10 @@ class RecommendationsCrud extends HTMLElement {
return p?.name || `Producto #${id}`;
}
+ getProduct(id) {
+ return this.allProducts.find(x => x.woo_product_id === id);
+ }
+
renderList() {
const list = this.shadowRoot.getElementById("list");
@@ -184,44 +258,79 @@ class RecommendationsCrud extends HTMLElement {
const el = document.createElement("div");
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
- // Mostrar productos trigger
- 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 ruleType = item.rule_type || "crosssell";
+ const triggerEvent = item.trigger_event || "";
- // Mostrar productos recomendados
- 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})` : "";
+ let contentHtml = "";
+ if (ruleType === "qty_per_person") {
+ contentHtml = `
+
Evento: ${triggerEvent || "General"}
+
Cantidades por persona configuradas
+ `;
+ } 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 = `
+
Cuando piden: ${triggerNames || "—"}${triggerMore}
+
→ Recomendar: ${recoNames || "—"}${recoMore}
+ `;
+ }
+ // Solo mostrar badge de tipo si no está filtrado
+ const typeBadge = !this.filterRuleType
+ ? `
${ruleType === "qty_per_person" ? "Cantidades" : "Cross-sell"} `
+ : "";
+
el.innerHTML = `
${item.rule_key}
+ ${typeBadge}
${item.active ? "Activa" : "Inactiva"}
P: ${item.priority}
-
Cuando piden: ${triggerNames || "—"}${triggerMore}
-
→ Recomendar: ${recoNames || "—"}${recoMore}
+ ${contentHtml}
`;
- el.onclick = () => {
- this.selected = item;
- this.editMode = "edit";
- this.selectedTriggerProducts = [...(item.trigger_product_ids || [])];
- this.selectedRecommendedProducts = [...(item.recommended_product_ids || [])];
- this.renderList();
- this.renderForm();
- };
-
+ el.onclick = () => this.selectItem(item);
list.appendChild(el);
}
}
+ async selectItem(item) {
+ // 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();
+ }
+
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();
}
@@ -243,10 +352,29 @@ class RecommendationsCrud extends HTMLElement {
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 ? `
+
+
Tipo de Regla
+
+
+
Cross-sell
+
Si pide A, ofrecer B, C, D
+
+
+
Cantidades
+
Cantidad por persona por producto
+
+
+
+ ` : ""}
+
@@ -264,24 +392,71 @@ class RecommendationsCrud extends HTMLElement {
-
-
Cuando el cliente pide...
-
-
-
-
+
+
+
Cuando el cliente pide...
+
+
Productos que activan esta recomendacion
+
+
+
+
Recomendar estos productos...
+
+
Productos a sugerir al cliente
-
Productos que activan esta recomendacion
-
-
Recomendar estos productos...
-
-
-
-
+
+
+
Tipo de Evento
+
+ General (cualquier evento)
+ Asado / Parrillada
+ Horno
+ Cumpleaños / Fiesta
+ Almuerzo / Cena
+
+
Evento que activa esta regla de cantidades
+
+
+
+
Productos y Cantidades por Persona
+
+
+
+ Producto
+ Para
+ Cantidad
+ Unidad
+ Razon
+
+
+
+
+ ${this.renderItemsRows()}
+
+
+
+
-
Productos a sugerir al cliente
@@ -298,9 +473,177 @@ class RecommendationsCrud extends HTMLElement {
this.shadowRoot.getElementById("deleteBtn").onclick = () => this.delete();
}
- // Setup product selectors
- this.setupProductSelector("trigger", this.selectedTriggerProducts);
- this.setupProductSelector("reco", this.selectedRecommendedProducts);
+ // 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 `
No hay productos configurados `;
+ }
+
+ 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 `
+
+ ${name}
+
+
+ Adulto
+ Niño
+ Todos
+
+
+
+
+
+ kg
+ g
+ unidad
+
+
+
+ ×
+
+ `;
+ }).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 => `
+
+ ${p.name}
+ $${p.price || 0}
+
+ `).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) {
+ alert("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) {
@@ -308,6 +651,8 @@ class RecommendationsCrud extends HTMLElement {
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) {
@@ -342,7 +687,7 @@ class RecommendationsCrud extends HTMLElement {
if (q) {
filtered = filtered.filter(p => p.name.toLowerCase().includes(q));
}
- filtered = filtered.slice(0, 50); // Limit for performance
+ filtered = filtered.slice(0, 50);
if (!q && !filtered.length) {
dropdown.classList.remove("open");
@@ -391,7 +736,6 @@ class RecommendationsCrud extends HTMLElement {
}
};
- // Close dropdown on outside click
document.addEventListener("click", (e) => {
if (!this.shadowRoot.getElementById(`${type}Selector`)?.contains(e.target)) {
dropdown.classList.remove("open");
@@ -411,27 +755,51 @@ class RecommendationsCrud extends HTMLElement {
return;
}
- if (!this.selectedTriggerProducts.length) {
- alert("Selecciona al menos un producto trigger");
- return;
- }
-
- if (!this.selectedRecommendedProducts.length) {
- alert("Selecciona al menos un producto para recomendar");
- return;
- }
-
const data = {
rule_key: ruleKey,
- trigger: {}, // Legacy field, keep empty
- queries: [], // Legacy field, keep empty
+ trigger: {},
+ queries: [],
ask_slots: [],
active,
priority,
- trigger_product_ids: this.selectedTriggerProducts,
- recommended_product_ids: this.selectedRecommendedProducts,
+ rule_type: this.currentRuleType,
+ trigger_event: null,
+ trigger_product_ids: [],
+ recommended_product_ids: [],
+ items: [],
};
+ if (this.currentRuleType === "crosssell") {
+ if (!this.selectedTriggerProducts.length) {
+ alert("Selecciona al menos un producto trigger");
+ return;
+ }
+ if (!this.selectedRecommendedProducts.length) {
+ alert("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) {
+ alert("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);
@@ -469,6 +837,7 @@ class RecommendationsCrud extends HTMLElement {
this.selected = null;
this.selectedTriggerProducts = [];
this.selectedRecommendedProducts = [];
+ this.ruleItems = [];
this.renderList();
this.renderForm();
}
diff --git a/public/components/users-crud.js b/public/components/users-crud.js
index 311b625..4956619 100644
--- a/public/components/users-crud.js
+++ b/public/components/users-crud.js
@@ -15,7 +15,7 @@ class UsersCrud extends HTMLElement {