From c7c56ddbfc29aacd8cdabd0bbfd998c78b22af41 Mon Sep 17 00:00:00 2001 From: Lucas Tettamanti <757326+lkzwieder@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:28:28 -0300 Subject: [PATCH] productos, equivalencias, cross-sell y cantidades --- .../20260119100000_reco_rule_items.sql | 39 + .../20260119100100_alias_product_mappings.sql | 24 + ...0260119110000_reco_rule_items_audience.sql | 12 + .../20260119120000_product_qty_rules.sql | 41 + public/app.js | 1 + public/components/aliases-crud.js | 262 ++- public/components/conversation-inspector.js | 47 +- public/components/conversations-crud.js | 2 +- public/components/ops-shell.js | 13 +- public/components/products-crud.js | 277 ++- public/components/quantities-crud.js | 340 +++ public/components/recommendations-crud.js | 481 ++++- public/components/users-crud.js | 2 +- public/lib/api.js | 23 + src/modules/0-ui/controllers/aliases.js | 8 +- src/modules/0-ui/controllers/quantities.js | 51 + .../0-ui/controllers/recommendations.js | 16 +- src/modules/0-ui/db/repo.js | 296 ++- src/modules/0-ui/handlers/aliases.js | 55 +- src/modules/0-ui/handlers/quantities.js | 23 + src/modules/0-ui/handlers/recommendations.js | 16 +- src/modules/1-intake/routes/simulator.js | 5 + src/modules/2-identity/db/repo.js | 91 +- src/modules/2-identity/services/pipeline.js | 23 +- src/modules/3-turn-engine/catalogRetrieval.js | 33 + src/modules/3-turn-engine/fsm.js | 341 ++- src/modules/3-turn-engine/openai.js | 177 +- src/modules/3-turn-engine/orderModel.js | 251 +++ src/modules/3-turn-engine/recommendations.js | 473 ++++- src/modules/3-turn-engine/stateHandlers.js | 858 ++++++++ src/modules/3-turn-engine/turnEngineV3.js | 1869 ++--------------- src/modules/shared/wooSnapshot.js | 6 +- 32 files changed, 4083 insertions(+), 2073 deletions(-) create mode 100644 db/migrations/20260119100000_reco_rule_items.sql create mode 100644 db/migrations/20260119100100_alias_product_mappings.sql create mode 100644 db/migrations/20260119110000_reco_rule_items_audience.sql create mode 100644 db/migrations/20260119120000_product_qty_rules.sql create mode 100644 public/components/quantities-crud.js create mode 100644 src/modules/0-ui/controllers/quantities.js create mode 100644 src/modules/0-ui/handlers/quantities.js create mode 100644 src/modules/3-turn-engine/orderModel.js create mode 100644 src/modules/3-turn-engine/stateHandlers.js 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 {
Detalle
-
Seleccioná un alias o creá uno nuevo
+
Selecciona un alias o crea uno nuevo
@@ -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 => - `` - ).join(""); - form.innerHTML = `
Sin tildes, en minusculas. Ej: "chimi" mapea a "Chimichurri"
-
- - -
-
- - -
Valor entre 0 y 10. Mayor boost = mayor prioridad en resultados
-
+
+ +
+ + + + + + + + + + + ${this.renderMappingsRows()} + +
ProductoScore (0-1)
+ +
+
+
+ +
+
+
+
+ +
+
+
Mayor score = mayor prioridad. Si hay ambiguedad, se pregunta al usuario.
+
+
${!isCreate ? `` : ""} @@ -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 { + +
+
+
Productos
+
+ +
+
+
Cargando...
+
+
+ +
+
Cantidades por Persona
+
+
Selecciona un producto para configurar cantidades
+
+
+
+ `; + } + + 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 ` + +
+ + +
+ + `; + }).join(""); + + return ` + + ${event.label} + ${cells} + + `; + }).join(""); + + form.innerHTML = ` +
+
${p.name}
+
$${p.price || 0} / ${p.sell_unit || 'kg'}
+
+ + + + + + ${PERSON_TYPES.map(pt => ``).join("")} + + + + ${rows} + +
Evento${pt.label}
+ +
+ + + +
+ `; + + 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
@@ -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 ? ` +
+ +
+
+
Cross-sell
+
Si pide A, ofrecer B, C, D
+
+
+
Cantidades
+
Cantidad por persona por producto
+
+
+
+ ` : ""} +
- +
Identificador unico, sin espacios
@@ -264,24 +392,71 @@ class RecommendationsCrud extends HTMLElement {
-
- -
- -
-
+
+
+ +
+ +
+
+
+
Productos que activan esta recomendacion
+
+ +
+ +
+ +
+
+
+
Productos a sugerir al cliente
-
Productos que activan esta recomendacion
-
- -
- -
-
+
+
+ + +
Evento que activa esta regla de cantidades
+
+ +
+ + + + + + + + + + + + + + ${this.renderItemsRows()} + +
ProductoParaCantidadUnidadRazon
+ +
+
+
+ +
+
+
+
+ +
+
-
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} + + + + + + + + + + + `; + }).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 {