modularizado de prompts
This commit is contained in:
@@ -10,6 +10,9 @@ import "./components/recommendations-crud.js";
|
||||
import "./components/quantities-crud.js";
|
||||
import "./components/orders-crud.js";
|
||||
import "./components/test-panel.js";
|
||||
import "./components/prompts-crud.js";
|
||||
import "./components/takeovers-crud.js";
|
||||
import "./components/settings-crud.js";
|
||||
import { connectSSE } from "./lib/sse.js";
|
||||
import { initRouter } from "./lib/router.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { emit, on } from "../lib/bus.js";
|
||||
import { navigateToView, navigateToItem } from "../lib/router.js";
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
class OpsShell extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -7,6 +8,7 @@ class OpsShell extends HTMLElement {
|
||||
this.attachShadow({ mode: "open" });
|
||||
this._currentView = "chat";
|
||||
this._currentParams = {};
|
||||
this._takeoverCount = 0;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@@ -22,6 +24,18 @@ class OpsShell extends HTMLElement {
|
||||
.spacer { flex:1; }
|
||||
.status { font-size:12px; color:var(--muted); }
|
||||
|
||||
/* Notification bell */
|
||||
.notification-bell { position:relative; cursor:pointer; padding:8px; margin-right:12px; }
|
||||
.notification-bell svg { width:20px; height:20px; fill:var(--muted); transition:fill .15s; }
|
||||
.notification-bell:hover svg { fill:var(--text); }
|
||||
.notification-bell.has-pending svg { fill:#f39c12; }
|
||||
.notification-bell .badge {
|
||||
position:absolute; top:2px; right:2px;
|
||||
background:#e74c3c; color:#fff;
|
||||
font-size:10px; padding:2px 6px; border-radius:10px;
|
||||
font-weight:700; min-width:18px; text-align:center;
|
||||
}
|
||||
|
||||
/* Layout para chat activo (2 columnas: burbujas + inspector) */
|
||||
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
|
||||
.col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
|
||||
@@ -48,9 +62,15 @@ class OpsShell extends HTMLElement {
|
||||
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
|
||||
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
|
||||
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
|
||||
<a class="nav-btn" href="/config-prompts" data-view="prompts">Prompts</a>
|
||||
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
|
||||
<a class="nav-btn" href="/test" data-view="test">Test</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<div class="notification-bell" id="notificationBell" title="Takeovers pendientes">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>
|
||||
<span class="badge" id="takeoverBadge" style="display:none;">0</span>
|
||||
</div>
|
||||
<div class="status" id="sseStatus">SSE: connecting…</div>
|
||||
</header>
|
||||
|
||||
@@ -109,6 +129,24 @@ class OpsShell extends HTMLElement {
|
||||
<test-panel></test-panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewPrompts" class="view">
|
||||
<div class="layout-crud">
|
||||
<prompts-crud></prompts-crud>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewTakeovers" class="view">
|
||||
<div class="layout-crud">
|
||||
<takeovers-crud></takeovers-crud>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewSettings" class="view">
|
||||
<div class="layout-crud">
|
||||
<settings-crud></settings-crud>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -138,12 +176,51 @@ class OpsShell extends HTMLElement {
|
||||
this.setView(view, {}, { updateUrl: true });
|
||||
};
|
||||
}
|
||||
|
||||
// Notification bell click
|
||||
const bell = this.shadowRoot.getElementById("notificationBell");
|
||||
bell.onclick = () => {
|
||||
this.setView("takeovers", {}, { updateUrl: true });
|
||||
};
|
||||
|
||||
// Start polling for takeovers
|
||||
this.pollTakeovers();
|
||||
this._pollInterval = setInterval(() => this.pollTakeovers(), 30000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._unsub?.();
|
||||
this._unsubSwitch?.();
|
||||
this._unsubRouter?.();
|
||||
if (this._pollInterval) clearInterval(this._pollInterval);
|
||||
}
|
||||
|
||||
async pollTakeovers() {
|
||||
try {
|
||||
const data = await api.takeovers({ limit: 1 });
|
||||
const count = data.pending_count || (data.items?.length || 0);
|
||||
this._takeoverCount = count;
|
||||
this.updateTakeoverBadge(count);
|
||||
} catch (e) {
|
||||
// Silently fail - don't break the UI
|
||||
console.debug("Error polling takeovers:", e);
|
||||
}
|
||||
}
|
||||
|
||||
updateTakeoverBadge(count) {
|
||||
const badge = this.shadowRoot.getElementById("takeoverBadge");
|
||||
const bell = this.shadowRoot.getElementById("notificationBell");
|
||||
|
||||
if (count > 0) {
|
||||
badge.textContent = count > 99 ? "99+" : count;
|
||||
badge.style.display = "inline";
|
||||
bell.classList.add("has-pending");
|
||||
bell.title = `${count} takeover(s) pendiente(s)`;
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
bell.classList.remove("has-pending");
|
||||
bell.title = "No hay takeovers pendientes";
|
||||
}
|
||||
}
|
||||
|
||||
setView(viewName, params = {}, { updateUrl = true } = {}) {
|
||||
|
||||
471
public/components/prompts-crud.js
Normal file
471
public/components/prompts-crud.js
Normal file
@@ -0,0 +1,471 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { on } from "../lib/bus.js";
|
||||
|
||||
const PROMPT_LABELS = {
|
||||
router: "Router (clasificador de dominio)",
|
||||
greeting: "Saludos",
|
||||
orders: "Pedidos",
|
||||
shipping: "Envio/Retiro",
|
||||
payment: "Pago",
|
||||
browse: "Consultas de catalogo",
|
||||
};
|
||||
|
||||
class PromptsCrud extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.items = [];
|
||||
this.selected = null;
|
||||
this.loading = false;
|
||||
this.versions = [];
|
||||
this.availableVariables = [];
|
||||
this.availableModels = [];
|
||||
this.currentSettings = {}; // Valores actuales de las variables
|
||||
this.testResult = null;
|
||||
this.testLoading = 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:280px 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; }
|
||||
|
||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
textarea { resize:vertical; min-height:200px; font-family:monospace; font-size:12px; line-height:1.5; }
|
||||
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; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
button.small { padding:4px 8px; font-size:11px; }
|
||||
|
||||
.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; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:14px; }
|
||||
.item-meta { font-size:11px; color:#8aa0b5; }
|
||||
.item-meta .default { color:#2ecc71; }
|
||||
.item-meta .custom { color:#f39c12; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.field { }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
||||
|
||||
.actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
|
||||
.variables-list { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.var-item { display:inline-flex; align-items:center; gap:4px; background:#0f1520; border:1px solid #253245; border-radius:4px; padding:2px 4px 2px 2px; }
|
||||
.var-btn { background:#253245; border:none; color:#8aa0b5; padding:4px 8px; border-radius:3px; font-size:11px; cursor:pointer; font-family:monospace; }
|
||||
.var-btn:hover { background:#1f6feb; color:#fff; }
|
||||
.var-value { font-size:10px; color:#6c7a89; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
.versions-list { max-height:150px; overflow-y:auto; margin-top:8px; }
|
||||
.version-item { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; background:#0f1520; border-radius:4px; margin-bottom:4px; font-size:12px; }
|
||||
.version-item.active { border-left:3px solid #2ecc71; }
|
||||
.version-item .ver { color:#e7eef7; }
|
||||
.version-item .date { color:#8aa0b5; font-size:10px; }
|
||||
|
||||
.test-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:16px; }
|
||||
.test-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
||||
.test-result { background:#0a0e14; border:1px solid #1e2a3a; border-radius:6px; padding:12px; margin-top:12px; font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
||||
.test-result.error { border-color:#e74c3c; color:#e74c3c; }
|
||||
.test-result.success { border-color:#2ecc71; }
|
||||
.test-meta { font-size:10px; color:#8aa0b5; margin-top:8px; }
|
||||
|
||||
.row { display:flex; gap:12px; align-items:flex-end; }
|
||||
.row .field { flex:1; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Prompts del Sistema</div>
|
||||
<div class="list" id="list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title" id="formTitle">Editor de Prompt</div>
|
||||
<div class="form" id="form">
|
||||
<div class="form-empty">Selecciona un prompt para editarlo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.load();
|
||||
|
||||
// Refrescar settings cuando se vuelve a esta vista (por si cambiaron en Config)
|
||||
this._unsubRouter = on("router:viewChanged", ({ view }) => {
|
||||
if (view === "prompts") {
|
||||
this.refreshSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._unsubRouter?.();
|
||||
}
|
||||
|
||||
async refreshSettings() {
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
this.currentSettings = {
|
||||
store_name: settings.store_name || "",
|
||||
store_hours: this.formatStoreHours(settings),
|
||||
store_address: settings.store_address || "",
|
||||
store_phone: settings.store_phone || "",
|
||||
bot_name: settings.bot_name || "",
|
||||
current_date: new Date().toLocaleDateString("es-AR"),
|
||||
customer_name: "(nombre del cliente)",
|
||||
state: "(estado actual)",
|
||||
};
|
||||
// Re-renderizar el form si hay uno seleccionado
|
||||
if (this.selected) {
|
||||
this.renderForm();
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug("Error refreshing settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.renderList();
|
||||
|
||||
try {
|
||||
// Cargar prompts y settings en paralelo
|
||||
const [data, settings] = await Promise.all([
|
||||
api.prompts(),
|
||||
api.getSettings().catch(() => ({})),
|
||||
]);
|
||||
|
||||
this.items = data.items || [];
|
||||
this.availableVariables = data.available_variables || [];
|
||||
this.availableModels = data.available_models || [];
|
||||
|
||||
// Mapear settings a variables
|
||||
this.currentSettings = {
|
||||
store_name: settings.store_name || "",
|
||||
store_hours: this.formatStoreHours(settings),
|
||||
store_address: settings.store_address || "",
|
||||
store_phone: settings.store_phone || "",
|
||||
bot_name: settings.bot_name || "",
|
||||
current_date: new Date().toLocaleDateString("es-AR"),
|
||||
customer_name: "(nombre del cliente)",
|
||||
state: "(estado actual)",
|
||||
};
|
||||
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
} catch (e) {
|
||||
console.error("Error loading prompts:", e);
|
||||
this.items = [];
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
formatStoreHours(settings) {
|
||||
if (!settings.pickup_days) return "";
|
||||
|
||||
// Mapeo de días cortos a nombres legibles
|
||||
const dayNames = {
|
||||
lun: "Lun", mar: "Mar", mie: "Mié", jue: "Jue",
|
||||
vie: "Vie", sab: "Sáb", dom: "Dom"
|
||||
};
|
||||
|
||||
const days = settings.pickup_days.split(",").map(d => dayNames[d.trim()] || d).join(", ");
|
||||
const start = (settings.pickup_hours_start || "08:00").slice(0, 5);
|
||||
const end = (settings.pickup_hours_end || "20:00").slice(0, 5);
|
||||
|
||||
return `${days} de ${start} a ${end}`;
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
|
||||
if (this.loading) {
|
||||
list.innerHTML = `<div class="loading">Cargando...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.items.length) {
|
||||
list.innerHTML = `<div class="loading">No se encontraron prompts</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const item of this.items) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.prompt_key === item.prompt_key ? " active" : "");
|
||||
|
||||
const label = PROMPT_LABELS[item.prompt_key] || item.prompt_key;
|
||||
const statusClass = item.is_default ? "default" : "custom";
|
||||
const statusText = item.is_default ? "Default" : `v${item.version}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-name">${label}</div>
|
||||
<div class="item-meta">
|
||||
<span class="${statusClass}">${statusText}</span>
|
||||
${item.model ? ` | ${item.model}` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.onclick = () => this.selectPrompt(item);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
async selectPrompt(item) {
|
||||
this.selected = item;
|
||||
this.testResult = null;
|
||||
this.renderList();
|
||||
|
||||
// Cargar detalles con versiones
|
||||
try {
|
||||
const details = await api.getPrompt(item.prompt_key);
|
||||
this.selected = { ...item, ...details.current };
|
||||
this.versions = details.versions || [];
|
||||
this.availableVariables = details.available_variables || this.availableVariables;
|
||||
this.availableModels = details.available_models || this.availableModels;
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
console.error("Error loading prompt details:", e);
|
||||
this.renderForm();
|
||||
}
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const form = this.shadowRoot.getElementById("form");
|
||||
const title = this.shadowRoot.getElementById("formTitle");
|
||||
|
||||
if (!this.selected) {
|
||||
title.textContent = "Editor de Prompt";
|
||||
form.innerHTML = `<div class="form-empty">Selecciona un prompt para editarlo</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const label = PROMPT_LABELS[this.selected.prompt_key] || this.selected.prompt_key;
|
||||
title.textContent = `Editar: ${label}`;
|
||||
|
||||
const content = this.selected.content || "";
|
||||
const model = this.selected.model || "gpt-4-turbo";
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label>Modelo LLM</label>
|
||||
<select id="modelSelect">
|
||||
${this.availableModels.map(m => `<option value="${m}" ${m === model ? "selected" : ""}>${m}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="flex:0">
|
||||
<label> </label>
|
||||
<button id="resetBtn" class="secondary">Reset a Default</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="flex:1; display:flex; flex-direction:column;">
|
||||
<label>Contenido del Prompt</label>
|
||||
<textarea id="contentInput" style="flex:1; min-height:250px;">${this.escapeHtml(content)}</textarea>
|
||||
<div class="field-hint">
|
||||
Variables disponibles (click para insertar):
|
||||
<div class="variables-list" id="variablesList">
|
||||
${this.availableVariables.map(v => {
|
||||
const key = typeof v === 'string' ? v : v.key;
|
||||
const desc = typeof v === 'string' ? '' : (v.description || '');
|
||||
const value = this.currentSettings[key] || '';
|
||||
const displayValue = value ? `= ${value}` : '(vacío)';
|
||||
return `<span class="var-item">
|
||||
<button class="var-btn" data-var="${key}" title="${desc}">{{${key}}}</button>
|
||||
<span class="var-value" title="${this.escapeHtml(value)}">${this.escapeHtml(displayValue)}</span>
|
||||
</span>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.versions.length > 0 ? `
|
||||
<div class="field">
|
||||
<label>Historial de Versiones</label>
|
||||
<div class="versions-list" id="versionsList">
|
||||
${this.versions.map(v => `
|
||||
<div class="version-item ${v.is_active ? "active" : ""}">
|
||||
<span class="ver">v${v.version} ${v.is_active ? "(activa)" : ""}</span>
|
||||
<span class="date">${this.formatDate(v.created_at)}</span>
|
||||
${!v.is_active ? `<button class="small secondary" data-version="${v.version}">Restaurar</button>` : ""}
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn">Guardar Cambios</button>
|
||||
<button id="testBtn" class="secondary">Probar Prompt</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section" id="testSection" style="display:none;">
|
||||
<h4>Probar Prompt</h4>
|
||||
<div class="field">
|
||||
<label>Mensaje de prueba</label>
|
||||
<input type="text" id="testMessage" placeholder="Ej: Hola, quiero 2kg de asado" />
|
||||
</div>
|
||||
<button id="runTestBtn" style="margin-top:8px;">Ejecutar Prueba</button>
|
||||
<div id="testResultContainer"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
|
||||
this.shadowRoot.getElementById("resetBtn").onclick = () => this.reset();
|
||||
this.shadowRoot.getElementById("testBtn").onclick = () => this.toggleTestSection();
|
||||
this.shadowRoot.getElementById("runTestBtn").onclick = () => this.runTest();
|
||||
|
||||
// Variable buttons
|
||||
this.shadowRoot.querySelectorAll(".var-btn").forEach(btn => {
|
||||
btn.onclick = () => this.insertVariable(btn.dataset.var);
|
||||
});
|
||||
|
||||
// Version restore buttons
|
||||
this.shadowRoot.querySelectorAll(".versions-list button").forEach(btn => {
|
||||
btn.onclick = () => this.rollback(parseInt(btn.dataset.version, 10));
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
insertVariable(varName) {
|
||||
const textarea = this.shadowRoot.getElementById("contentInput");
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const insertion = `{{${varName}}}`;
|
||||
textarea.value = text.slice(0, start) + insertion + text.slice(end);
|
||||
textarea.selectionStart = textarea.selectionEnd = start + insertion.length;
|
||||
textarea.focus();
|
||||
}
|
||||
|
||||
toggleTestSection() {
|
||||
const section = this.shadowRoot.getElementById("testSection");
|
||||
section.style.display = section.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
async save() {
|
||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
||||
const model = this.shadowRoot.getElementById("modelSelect").value;
|
||||
|
||||
if (!content.trim()) {
|
||||
alert("El contenido no puede estar vacio");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.savePrompt(this.selected.prompt_key, { content, model });
|
||||
alert("Prompt guardado correctamente");
|
||||
await this.load();
|
||||
// Re-seleccionar el prompt actual
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error saving prompt:", e);
|
||||
alert("Error guardando: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async reset() {
|
||||
if (!confirm("Esto desactivara todas las versiones custom y volvera al prompt por defecto. Continuar?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.resetPrompt(this.selected.prompt_key);
|
||||
alert("Prompt reseteado a default");
|
||||
await this.load();
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error resetting prompt:", e);
|
||||
alert("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async rollback(version) {
|
||||
if (!confirm(`Restaurar version ${version}? Se creara una nueva version con ese contenido.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.rollbackPrompt(this.selected.prompt_key, version);
|
||||
alert("Version restaurada");
|
||||
await this.load();
|
||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
||||
if (updated) this.selectPrompt(updated);
|
||||
} catch (e) {
|
||||
console.error("Error rolling back:", e);
|
||||
alert("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async runTest() {
|
||||
const testMessage = this.shadowRoot.getElementById("testMessage").value;
|
||||
if (!testMessage.trim()) {
|
||||
alert("Ingresa un mensaje de prueba");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
||||
const container = this.shadowRoot.getElementById("testResultContainer");
|
||||
container.innerHTML = `<div class="test-result">Ejecutando prueba...</div>`;
|
||||
|
||||
try {
|
||||
const result = await api.testPrompt(this.selected.prompt_key, {
|
||||
content,
|
||||
test_message: testMessage,
|
||||
store_config: { store_name: "Carniceria Demo", bot_name: "Piaf" },
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
let parsed = result.response;
|
||||
try {
|
||||
parsed = JSON.stringify(JSON.parse(result.response), null, 2);
|
||||
} catch (e) { /* no es JSON */ }
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="test-result success">${this.escapeHtml(parsed)}</div>
|
||||
<div class="test-meta">
|
||||
Modelo: ${result.model} | Latencia: ${result.latency_ms}ms |
|
||||
Tokens: ${result.usage?.total_tokens || "?"}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
container.innerHTML = `<div class="test-result error">Error: ${result.error || "Unknown"}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error testing prompt:", e);
|
||||
container.innerHTML = `<div class="test-result error">Error: ${e.message || e}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("prompts-crud", PromptsCrud);
|
||||
371
public/components/settings-crud.js
Normal file
371
public/components/settings-crud.js
Normal file
@@ -0,0 +1,371 @@
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
const DAYS = [
|
||||
{ id: "lun", label: "Lun" },
|
||||
{ id: "mar", label: "Mar" },
|
||||
{ id: "mie", label: "Mié" },
|
||||
{ id: "jue", label: "Jue" },
|
||||
{ id: "vie", label: "Vie" },
|
||||
{ id: "sab", label: "Sáb" },
|
||||
{ id: "dom", label: "Dom" },
|
||||
];
|
||||
|
||||
class SettingsCrud extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.settings = null;
|
||||
this.loading = false;
|
||||
this.saving = false;
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display:block; height:100%; padding:16px; overflow:auto; }
|
||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||
.container { max-width:800px; margin:0 auto; }
|
||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:20px; margin-bottom:16px; }
|
||||
.panel-title { font-size:16px; font-weight:700; color:#e7eef7; margin-bottom:16px; display:flex; align-items:center; gap:8px; }
|
||||
.panel-title svg { width:20px; height:20px; fill:#1f6feb; }
|
||||
|
||||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||
.form-row.full { grid-template-columns:1fr; }
|
||||
|
||||
.field { }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.field-hint { font-size:11px; color:#6c7a89; margin-top:4px; }
|
||||
|
||||
input, select, textarea {
|
||||
background:#0f1520; color:#e7eef7; border:1px solid #253245;
|
||||
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
input:disabled { opacity:.6; cursor:not-allowed; }
|
||||
|
||||
button {
|
||||
cursor:pointer; background:#1f6feb; color:#fff; border:none;
|
||||
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
|
||||
}
|
||||
button:hover { background:#1a5fd0; }
|
||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
button.secondary { background:#253245; }
|
||||
button.secondary:hover { background:#2d3e52; }
|
||||
|
||||
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:12px; }
|
||||
.toggle {
|
||||
position:relative; width:48px; height:26px;
|
||||
background:#253245; border-radius:13px; cursor:pointer;
|
||||
transition:background .2s;
|
||||
}
|
||||
.toggle.active { background:#1f6feb; }
|
||||
.toggle::after {
|
||||
content:''; position:absolute; top:3px; left:3px;
|
||||
width:20px; height:20px; background:#fff; border-radius:50%;
|
||||
transition:transform .2s;
|
||||
}
|
||||
.toggle.active::after { transform:translateX(22px); }
|
||||
.toggle-label { font-size:14px; color:#e7eef7; }
|
||||
|
||||
.days-selector { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.day-btn {
|
||||
padding:8px 12px; border-radius:6px; font-size:12px; font-weight:600;
|
||||
background:#253245; color:#8aa0b5; border:none; cursor:pointer;
|
||||
transition:all .15s;
|
||||
}
|
||||
.day-btn:hover { background:#2d3e52; color:#e7eef7; }
|
||||
.day-btn.selected { background:#1f6feb; color:#fff; }
|
||||
|
||||
.hours-row { display:flex; align-items:center; gap:12px; margin-top:12px; }
|
||||
.hours-row input { width:90px; text-align:center; font-family:monospace; font-size:15px; letter-spacing:1px; }
|
||||
.hours-row input::placeholder { color:#6c7a89; }
|
||||
.hours-row span { color:#8aa0b5; }
|
||||
.hours-row .hour-hint { font-size:11px; color:#6c7a89; margin-left:8px; }
|
||||
|
||||
.actions { display:flex; gap:12px; margin-top:24px; }
|
||||
.loading { text-align:center; padding:60px; color:#8aa0b5; }
|
||||
|
||||
.success-msg {
|
||||
background:#2ecc7130; border:1px solid #2ecc71;
|
||||
color:#2ecc71; padding:12px 16px; border-radius:8px;
|
||||
margin-bottom:16px; font-size:14px;
|
||||
}
|
||||
.error-msg {
|
||||
background:#e74c3c30; border:1px solid #e74c3c;
|
||||
color:#e74c3c; padding:12px 16px; border-radius:8px;
|
||||
margin-bottom:16px; font-size:14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div id="messages"></div>
|
||||
<div id="content">
|
||||
<div class="loading">Cargando configuración...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
this.settings = await api.getSettings();
|
||||
this.loading = false;
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.error("Error loading settings:", e);
|
||||
this.loading = false;
|
||||
this.showError("Error cargando configuración: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = this.shadowRoot.getElementById("content");
|
||||
|
||||
if (this.loading) {
|
||||
content.innerHTML = `<div class="loading">Cargando configuración...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.settings) {
|
||||
content.innerHTML = `<div class="loading">No se pudo cargar la configuración</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = this.settings;
|
||||
const deliveryDays = (s.delivery_days || "").split(",").filter(d => d);
|
||||
const pickupDays = (s.pickup_days || "").split(",").filter(d => d);
|
||||
|
||||
content.innerHTML = `
|
||||
<!-- Info del Negocio -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||
Información del Negocio
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Nombre del negocio</label>
|
||||
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
|
||||
<div class="field-hint">Se usa en los mensajes del bot</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nombre del bot</label>
|
||||
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
|
||||
<div class="field-hint">El asistente virtual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Dirección</label>
|
||||
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Ej: Av. Corrientes 1234, CABA" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Teléfono</label>
|
||||
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="Ej: +54 11 1234-5678" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
|
||||
Delivery (Envío a domicilio)
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle ${s.delivery_enabled ? "active" : ""}" id="deliveryToggle"></div>
|
||||
<span class="toggle-label">Delivery habilitado</span>
|
||||
</div>
|
||||
|
||||
<div id="deliveryOptions" style="${s.delivery_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
|
||||
<div class="field">
|
||||
<label>Días disponibles</label>
|
||||
<div class="days-selector" id="deliveryDays">
|
||||
${DAYS.map(d => `
|
||||
<button type="button" class="day-btn ${deliveryDays.includes(d.id) ? "selected" : ""}" data-day="${d.id}">${d.label}</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hours-row">
|
||||
<span>Horario:</span>
|
||||
<input type="text" id="deliveryStart" value="${s.delivery_hours_start || "09:00"}" placeholder="09:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||
<span>a</span>
|
||||
<input type="text" id="deliveryEnd" value="${s.delivery_hours_end || "18:00"}" placeholder="18:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||
<span class="hour-hint">(formato 24hs)</span>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top:12px;">
|
||||
<label>Pedido mínimo ($)</label>
|
||||
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retiro en tienda -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
|
||||
Retiro en Tienda
|
||||
</div>
|
||||
|
||||
<div class="toggle-row">
|
||||
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
|
||||
<span class="toggle-label">Retiro en tienda habilitado</span>
|
||||
</div>
|
||||
|
||||
<div id="pickupOptions" style="${s.pickup_enabled ? "" : "opacity:0.5;pointer-events:none;"}">
|
||||
<div class="field">
|
||||
<label>Días disponibles</label>
|
||||
<div class="days-selector" id="pickupDays">
|
||||
${DAYS.map(d => `
|
||||
<button type="button" class="day-btn ${pickupDays.includes(d.id) ? "selected" : ""}" data-day="${d.id}">${d.label}</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hours-row">
|
||||
<span>Horario:</span>
|
||||
<input type="text" id="pickupStart" value="${s.pickup_hours_start || "08:00"}" placeholder="08:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||
<span>a</span>
|
||||
<input type="text" id="pickupEnd" value="${s.pickup_hours_end || "20:00"}" placeholder="20:00" pattern="([01]?[0-9]|2[0-3]):[0-5][0-9]" maxlength="5" />
|
||||
<span class="hour-hint">(formato 24hs)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
|
||||
<button id="resetBtn" class="secondary">Restaurar</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Toggle delivery
|
||||
const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle");
|
||||
deliveryToggle?.addEventListener("click", () => {
|
||||
this.settings.delivery_enabled = !this.settings.delivery_enabled;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Toggle pickup
|
||||
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
|
||||
pickupToggle?.addEventListener("click", () => {
|
||||
this.settings.pickup_enabled = !this.settings.pickup_enabled;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Delivery days
|
||||
this.shadowRoot.querySelectorAll("#deliveryDays .day-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const day = btn.dataset.day;
|
||||
let days = (this.settings.delivery_days || "").split(",").filter(d => d);
|
||||
if (days.includes(day)) {
|
||||
days = days.filter(d => d !== day);
|
||||
} else {
|
||||
days.push(day);
|
||||
}
|
||||
// Ordenar días
|
||||
days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b));
|
||||
this.settings.delivery_days = days.join(",");
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Pickup days
|
||||
this.shadowRoot.querySelectorAll("#pickupDays .day-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const day = btn.dataset.day;
|
||||
let days = (this.settings.pickup_days || "").split(",").filter(d => d);
|
||||
if (days.includes(day)) {
|
||||
days = days.filter(d => d !== day);
|
||||
} else {
|
||||
days.push(day);
|
||||
}
|
||||
days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b));
|
||||
this.settings.pickup_days = days.join(",");
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
// Save button
|
||||
this.shadowRoot.getElementById("saveBtn")?.addEventListener("click", () => this.save());
|
||||
|
||||
// Reset button
|
||||
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
|
||||
}
|
||||
|
||||
async save() {
|
||||
// Collect form data BEFORE re-rendering
|
||||
const data = {
|
||||
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
||||
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
||||
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
||||
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
||||
delivery_enabled: this.settings.delivery_enabled,
|
||||
delivery_days: this.settings.delivery_days,
|
||||
delivery_hours_start: this.shadowRoot.getElementById("deliveryStart")?.value || "09:00",
|
||||
delivery_hours_end: this.shadowRoot.getElementById("deliveryEnd")?.value || "18:00",
|
||||
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
|
||||
pickup_enabled: this.settings.pickup_enabled,
|
||||
pickup_days: this.settings.pickup_days,
|
||||
pickup_hours_start: this.shadowRoot.getElementById("pickupStart")?.value || "08:00",
|
||||
pickup_hours_end: this.shadowRoot.getElementById("pickupEnd")?.value || "20:00",
|
||||
};
|
||||
|
||||
// Update settings with form values so they persist through render
|
||||
this.settings = { ...this.settings, ...data };
|
||||
|
||||
this.saving = true;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
console.log("[settings-crud] Saving:", data);
|
||||
const result = await api.saveSettings(data);
|
||||
console.log("[settings-crud] Save result:", result);
|
||||
|
||||
if (result.ok === false) {
|
||||
throw new Error(result.message || result.error || "Error desconocido");
|
||||
}
|
||||
|
||||
this.settings = result.settings || data;
|
||||
this.saving = false;
|
||||
this.showSuccess(result.message || "Configuración guardada correctamente");
|
||||
this.render();
|
||||
} catch (e) {
|
||||
console.error("[settings-crud] Error saving settings:", e);
|
||||
this.saving = false;
|
||||
this.showError("Error guardando: " + (e.message || e));
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
showSuccess(msg) {
|
||||
const messages = this.shadowRoot.getElementById("messages");
|
||||
messages.innerHTML = `<div class="success-msg">${msg}</div>`;
|
||||
setTimeout(() => { messages.innerHTML = ""; }, 4000);
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
const messages = this.shadowRoot.getElementById("messages");
|
||||
messages.innerHTML = `<div class="error-msg">${msg}</div>`;
|
||||
setTimeout(() => { messages.innerHTML = ""; }, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("settings-crud", SettingsCrud);
|
||||
409
public/components/takeovers-crud.js
Normal file
409
public/components/takeovers-crud.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { api } from "../lib/api.js";
|
||||
import { on } from "../lib/bus.js";
|
||||
|
||||
class TakeoversCrud extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.items = [];
|
||||
this.selected = null;
|
||||
this.loading = false;
|
||||
this.products = [];
|
||||
this.pendingCount = 0;
|
||||
|
||||
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:350px 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; display:flex; align-items:center; gap:8px; }
|
||||
.panel-title .badge { background:#e74c3c; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
|
||||
|
||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
||||
textarea { resize:vertical; min-height:100px; }
|
||||
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; }
|
||||
button.danger { background:#e74c3c; }
|
||||
button.danger:hover { background:#c0392b; }
|
||||
|
||||
.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; }
|
||||
.item:hover { border-color:#1f6feb; }
|
||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
||||
.item-query { font-weight:600; color:#f39c12; margin-bottom:4px; font-size:14px; }
|
||||
.item-reason { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
|
||||
.item-time { font-size:11px; color:#6c7a89; }
|
||||
.item-chat { font-size:11px; color:#1f6feb; }
|
||||
|
||||
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
||||
.field { }
|
||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
|
||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
||||
|
||||
.conversation-history { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
|
||||
.msg { margin-bottom:8px; padding:8px; border-radius:6px; font-size:12px; }
|
||||
.msg.user { background:#1a2a3a; border-left:3px solid #1f6feb; }
|
||||
.msg.assistant { background:#1a2535; border-left:3px solid #2ecc71; }
|
||||
.msg-role { font-size:10px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; }
|
||||
.msg-content { color:#e7eef7; white-space:pre-wrap; }
|
||||
|
||||
.query-highlight { background:#f39c1230; border:1px solid #f39c12; border-radius:8px; padding:12px; margin-bottom:16px; }
|
||||
.query-highlight label { color:#f39c12; }
|
||||
.query-highlight .query { font-size:16px; font-weight:600; color:#f39c12; margin-top:4px; }
|
||||
|
||||
.alias-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:12px; }
|
||||
.alias-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
||||
.checkbox-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
|
||||
.checkbox-row input[type="checkbox"] { width:auto; }
|
||||
.checkbox-row label { font-size:13px; color:#e7eef7; text-transform:none; }
|
||||
|
||||
.product-selector { position:relative; }
|
||||
.product-dropdown {
|
||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
||||
max-height:200px; overflow-y:auto; display:none;
|
||||
}
|
||||
.product-dropdown.open { display:block; }
|
||||
.product-option {
|
||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
}
|
||||
.product-option:hover { background:#1a2535; }
|
||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
||||
|
||||
.no-pending { text-align:center; padding:60px 20px; color:#2ecc71; }
|
||||
.no-pending svg { width:48px; height:48px; fill:#2ecc71; margin-bottom:16px; }
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
Takeovers Pendientes
|
||||
<span class="badge" id="pendingBadge" style="display:none;">0</span>
|
||||
</div>
|
||||
<div class="list" id="list">
|
||||
<div class="loading">Cargando...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">Responder</div>
|
||||
<div class="form" id="form">
|
||||
<div class="form-empty">Selecciona un takeover para responder</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.load();
|
||||
this.loadProducts();
|
||||
|
||||
// Refresh cuando se recibe evento SSE de nuevo takeover
|
||||
this._unsubSse = on("sse:takeover", () => this.load());
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._unsubSse?.();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.renderList();
|
||||
|
||||
try {
|
||||
const data = await api.takeovers({ limit: 50 });
|
||||
this.items = data.items || [];
|
||||
this.pendingCount = data.pending_count || this.items.length;
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
} catch (e) {
|
||||
console.error("Error loading takeovers:", e);
|
||||
this.items = [];
|
||||
this.loading = false;
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
|
||||
async loadProducts() {
|
||||
try {
|
||||
const data = await api.products({ limit: 2000 });
|
||||
this.products = data.items || [];
|
||||
} catch (e) {
|
||||
console.error("Error loading products:", e);
|
||||
this.products = [];
|
||||
}
|
||||
}
|
||||
|
||||
getProductName(id) {
|
||||
const p = this.products.find(x => x.woo_product_id === id);
|
||||
return p?.name || `Producto #${id}`;
|
||||
}
|
||||
|
||||
formatTime(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return "Hace un momento";
|
||||
if (diffMins < 60) return `Hace ${diffMins} min`;
|
||||
if (diffMins < 1440) return `Hace ${Math.floor(diffMins / 60)} hs`;
|
||||
return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const list = this.shadowRoot.getElementById("list");
|
||||
const badge = this.shadowRoot.getElementById("pendingBadge");
|
||||
|
||||
if (this.pendingCount > 0) {
|
||||
badge.textContent = this.pendingCount;
|
||||
badge.style.display = "inline";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.loading) {
|
||||
list.innerHTML = `<div class="loading">Cargando...</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.items.length) {
|
||||
list.innerHTML = `
|
||||
<div class="no-pending">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/></svg>
|
||||
<div>No hay takeovers pendientes</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const item of this.items) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "item" + (this.selected?.id === item.id ? " active" : "");
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="item-query">"${item.pending_query}"</div>
|
||||
<div class="item-reason">${this.getReasonLabel(item.reason)}</div>
|
||||
<div class="item-chat">Chat: ${item.chat_id}</div>
|
||||
<div class="item-time">${this.formatTime(item.created_at)}</div>
|
||||
`;
|
||||
|
||||
el.onclick = () => this.selectTakeover(item);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
getReasonLabel(reason) {
|
||||
const labels = {
|
||||
product_not_found: "Producto no encontrado",
|
||||
low_confidence: "Baja confianza del NLU",
|
||||
ambiguous: "Consulta ambigua",
|
||||
};
|
||||
return labels[reason] || reason;
|
||||
}
|
||||
|
||||
async selectTakeover(item) {
|
||||
this.selected = item;
|
||||
this.renderList();
|
||||
|
||||
// Cargar detalles con historial
|
||||
try {
|
||||
const details = await api.getTakeover(item.id);
|
||||
this.selected = { ...item, ...details };
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
console.error("Error loading takeover details:", e);
|
||||
this.renderForm();
|
||||
}
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const form = this.shadowRoot.getElementById("form");
|
||||
|
||||
if (!this.selected) {
|
||||
form.innerHTML = `<div class="form-empty">Selecciona un takeover para responder</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const history = this.selected.conversation_history || this.selected.recent_messages || [];
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="query-highlight">
|
||||
<label>Consulta del cliente</label>
|
||||
<div class="query">"${this.selected.pending_query}"</div>
|
||||
</div>
|
||||
|
||||
${history.length > 0 ? `
|
||||
<div class="field">
|
||||
<label>Historial de conversacion</label>
|
||||
<div class="conversation-history">
|
||||
${history.slice(-10).map(m => `
|
||||
<div class="msg ${m.role}">
|
||||
<div class="msg-role">${m.role === "user" ? "Cliente" : "Bot"}</div>
|
||||
<div class="msg-content">${this.escapeHtml((m.content || "").slice(0, 300))}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
<div class="field" style="flex:1;">
|
||||
<label>Tu respuesta (se enviara como el bot)</label>
|
||||
<textarea id="responseInput" placeholder="Ej: Disculpa, no tenemos ese producto. Pero tenemos..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="alias-section">
|
||||
<h4>Agregar Alias (opcional)</h4>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="addAliasCheck" />
|
||||
<label for="addAliasCheck">Agregar "${this.selected.pending_query}" como alias de un producto</label>
|
||||
</div>
|
||||
<div id="aliasProductSection" style="display:none;">
|
||||
<div class="field">
|
||||
<label>Seleccionar producto</label>
|
||||
<div class="product-selector" id="productSelector">
|
||||
<input type="text" id="productSearch" placeholder="Buscar producto..." />
|
||||
<div class="product-dropdown" id="productDropdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="respondBtn">Enviar Respuesta</button>
|
||||
<button id="cancelBtn" class="danger">Cancelar Takeover</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
this.shadowRoot.getElementById("respondBtn").onclick = () => this.respond();
|
||||
this.shadowRoot.getElementById("cancelBtn").onclick = () => this.cancel();
|
||||
|
||||
const addAliasCheck = this.shadowRoot.getElementById("addAliasCheck");
|
||||
const aliasSection = this.shadowRoot.getElementById("aliasProductSection");
|
||||
addAliasCheck.onchange = () => {
|
||||
aliasSection.style.display = addAliasCheck.checked ? "block" : "none";
|
||||
};
|
||||
|
||||
this.setupProductSelector();
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
setupProductSelector() {
|
||||
const searchInput = this.shadowRoot.getElementById("productSearch");
|
||||
const dropdown = this.shadowRoot.getElementById("productDropdown");
|
||||
|
||||
if (!searchInput || !dropdown) return;
|
||||
|
||||
this._selectedProductId = null;
|
||||
|
||||
const renderDropdown = (query) => {
|
||||
const q = (query || "").toLowerCase().trim();
|
||||
let filtered = this.products;
|
||||
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 => `
|
||||
<div class="product-option" data-id="${p.woo_product_id}">
|
||||
<span>${p.name}</span>
|
||||
<span class="price">$${p.price || 0}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
dropdown.querySelectorAll(".product-option").forEach(opt => {
|
||||
opt.onclick = () => {
|
||||
this._selectedProductId = parseInt(opt.dataset.id, 10);
|
||||
searchInput.value = this.getProductName(this._selectedProductId);
|
||||
dropdown.classList.remove("open");
|
||||
};
|
||||
});
|
||||
|
||||
dropdown.classList.add("open");
|
||||
};
|
||||
|
||||
searchInput.oninput = () => {
|
||||
this._selectedProductId = null;
|
||||
clearTimeout(this._searchTimer);
|
||||
this._searchTimer = setTimeout(() => renderDropdown(searchInput.value), 150);
|
||||
};
|
||||
|
||||
searchInput.onfocus = () => renderDropdown(searchInput.value);
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.shadowRoot.getElementById("productSelector")?.contains(e.target)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async respond() {
|
||||
const response = this.shadowRoot.getElementById("responseInput").value.trim();
|
||||
if (!response) {
|
||||
alert("Escribe una respuesta");
|
||||
return;
|
||||
}
|
||||
|
||||
const addAliasCheck = this.shadowRoot.getElementById("addAliasCheck");
|
||||
let addAlias = null;
|
||||
|
||||
if (addAliasCheck.checked && this._selectedProductId) {
|
||||
addAlias = {
|
||||
query: this.selected.pending_query,
|
||||
woo_product_id: this._selectedProductId,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await api.respondTakeover(this.selected.id, { response, add_alias: addAlias });
|
||||
alert("Respuesta enviada");
|
||||
this.selected = null;
|
||||
await this.load();
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
console.error("Error responding:", e);
|
||||
alert("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async cancel() {
|
||||
if (!confirm("Cancelar este takeover? El cliente no recibira respuesta automatica.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.cancelTakeover(this.selected.id);
|
||||
alert("Takeover cancelado");
|
||||
this.selected = null;
|
||||
await this.load();
|
||||
this.renderForm();
|
||||
} catch (e) {
|
||||
console.error("Error cancelling:", e);
|
||||
alert("Error: " + (e.message || e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("takeovers-crud", TakeoversCrud);
|
||||
@@ -219,4 +219,84 @@ export const api = {
|
||||
body: JSON.stringify({ woo_order_id, amount }),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
// --- Prompts CRUD ---
|
||||
async prompts() {
|
||||
return fetch("/prompts").then(r => r.json());
|
||||
},
|
||||
|
||||
async getPrompt(key) {
|
||||
return fetch(`/prompts/${encodeURIComponent(key)}`).then(r => r.json());
|
||||
},
|
||||
|
||||
async savePrompt(key, { content, model, created_by }) {
|
||||
return fetch(`/prompts/${encodeURIComponent(key)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content, model, created_by }),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
async rollbackPrompt(key, version) {
|
||||
return fetch(`/prompts/${encodeURIComponent(key)}/rollback/${version}`, {
|
||||
method: "POST"
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
async resetPrompt(key) {
|
||||
return fetch(`/prompts/${encodeURIComponent(key)}/reset`, {
|
||||
method: "POST"
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
async testPrompt(key, { content, test_message, store_config }) {
|
||||
return fetch(`/prompts/${encodeURIComponent(key)}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content, test_message, store_config }),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
// --- Human Takeovers ---
|
||||
async takeovers({ limit = 50 } = {}) {
|
||||
const u = new URL("/takeovers", location.origin);
|
||||
u.searchParams.set("limit", String(limit));
|
||||
return fetch(u).then(r => r.json());
|
||||
},
|
||||
|
||||
async getTakeover(id) {
|
||||
return fetch(`/takeovers/${id}`).then(r => r.json());
|
||||
},
|
||||
|
||||
async respondTakeover(id, { response, add_alias }) {
|
||||
return fetch(`/takeovers/${id}/respond`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response, add_alias }),
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
async cancelTakeover(id) {
|
||||
return fetch(`/takeovers/${id}/cancel`, {
|
||||
method: "POST"
|
||||
}).then(r => r.json());
|
||||
},
|
||||
|
||||
// --- Settings ---
|
||||
async getSettings() {
|
||||
return fetch("/settings").then(r => r.json());
|
||||
},
|
||||
|
||||
async saveSettings(settings) {
|
||||
const res = await fetch("/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.message || data.error || "Error guardando configuración");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,9 @@ const ROUTES = [
|
||||
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
|
||||
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
|
||||
{ pattern: /^\/test$/, view: "test", params: [] },
|
||||
{ pattern: /^\/config-prompts$/, view: "prompts", params: [] },
|
||||
{ pattern: /^\/atencion-humana$/, view: "takeovers", params: [] },
|
||||
{ pattern: /^\/configuracion$/, view: "settings", params: [] },
|
||||
];
|
||||
|
||||
// Mapeo de vistas a rutas base (para navegación sin parámetros)
|
||||
@@ -29,6 +32,9 @@ const VIEW_TO_PATH = {
|
||||
quantities: "/cantidades",
|
||||
orders: "/pedidos",
|
||||
test: "/test",
|
||||
prompts: "/config-prompts",
|
||||
takeovers: "/atencion-humana",
|
||||
settings: "/configuracion",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user