// ========================================================================== // CLIENT ENGINE - MP MANAGER // ========================================================================== let branchesData = []; let activeBranchId = "global"; // Default: Consolidado Global let activePipelineId = ""; let currentContactsPage = 1; let contactsSearchQuery = ""; let activeTab = "tab-dashboard"; let contactsWithoutOppFilter = false; let isSyncingGlobally = false; let syncProgressInterval = null; let activeScriptsCategory = "all"; let scriptsSearchQuery = ""; // ========================================================================== // APP MODE — Toggle global Dry-Run / Live // ========================================================================== // Modo universal de la plataforma. Cuando el usuario lo cambia, TODAS las // acciones mutantes lo respetan: el frontend inyecta el header X-Dry-Run en // cada fetch mutante (usar mutateFetch), y el backend chequea ese header para // ramificar entre simulación y escritura real en Bucéfalo. // // Default: dry-run (más seguro). Persiste en localStorage entre sesiones. // Cualquier cambio dispara el evento "app-mode-changed" para que componentes // suelten su UI específica (ej. el botón de Scripts cambia su label). const APP_MODE_STORAGE_KEY = "mp_manager_app_mode"; window.AppMode = (function () { function read() { try { const raw = localStorage.getItem(APP_MODE_STORAGE_KEY); if (raw === "live") return "live"; return "dryrun"; } catch (_) { return "dryrun"; } } function write(mode) { try { localStorage.setItem(APP_MODE_STORAGE_KEY, mode); } catch (_) {} } let current = read(); return { get isDryRun() { return current === "dryrun"; }, get isLive() { return current === "live"; }, get value() { return current; }, set(mode) { const next = mode === "live" ? "live" : "dryrun"; if (next === current) return; current = next; write(next); window.dispatchEvent(new CustomEvent("app-mode-changed", { detail: { mode: next } })); }, toggle() { this.set(current === "dryrun" ? "live" : "dryrun"); }, }; })(); /** * fetch wrapper para llamadas MUTANTES (POST/PUT/PATCH/DELETE). * Inyecta el header X-Dry-Run según el modo global. * * Reglas: * - SIEMPRE usar mutateFetch para acciones que escriben en Bucéfalo o que * llaman scripts mutadores (no GETs ni endpoints read-only). * - El backend respeta el header. Si tu endpoint aún no lo respeta, sigue * ejecutando real — el header es informativo. Hay que actualizar el * endpoint para que ramifique. */ async function mutateFetch(url, options = {}) { const headers = new Headers(options.headers || {}); headers.set("X-Dry-Run", window.AppMode.isDryRun ? "1" : "0"); headers.set("X-App-Mode", window.AppMode.value); return fetch(url, { ...options, headers }); } /** * Inicializa el toggle global del header. Sincroniza estado, dispara eventos, * actualiza estilos. Se llama una vez en DOMContentLoaded. */ function initGlobalModeToggle() { const wrapper = document.getElementById("global-mode-toggle"); const checkbox = document.getElementById("global-mode-checkbox"); const valueLabel = document.getElementById("global-mode-value"); if (!wrapper || !checkbox || !valueLabel) return; function syncUI() { const isDryRun = window.AppMode.isDryRun; checkbox.checked = !isDryRun; // checked = live (peligroso) wrapper.classList.toggle("is-dryrun", isDryRun); wrapper.classList.toggle("is-live", !isDryRun); valueLabel.textContent = isDryRun ? "Simulación" : "Live (Real)"; wrapper.title = isDryRun ? "Modo Simulación (Dry-Run): las acciones se simulan sin tocar Bucéfalo. Cambia a Live para escribir real." : "MODO LIVE: las acciones modifican Bucéfalo de verdad. Cambia a Simulación para volver a un modo seguro."; const icon = wrapper.querySelector(".global-mode-icon i"); if (icon) { icon.className = isDryRun ? "fa-solid fa-shield-halved" : "fa-solid fa-bolt"; } } // Click en el wrapper o en el checkbox alterna el modo. function handleToggle(e) { if (e && e.target && (e.target.closest("input[type=checkbox]") || e.target.closest(".toggle-switch"))) { // El checkbox/switch maneja su propio click; nosotros respondemos al change. return; } e?.stopPropagation(); confirmAndToggle(); } async function confirmAndToggle() { const goingToLive = window.AppMode.isDryRun; if (goingToLive) { const ok = await appConfirm({ title: "Activar modo LIVE", severity: "danger", message: ` Vas a salir del modo simulación. A partir de ahora, todas las acciones de la plataforma escribirán en Bucéfalo de verdad (eliminar contactos, crear oportunidades, sincronizar Marca↔Sucursal, etc.).

Si tu intención es probar primero, mantén el modo en Simulación. `, confirmText: "Sí, activar Live", cancelText: "Quedarme en Simulación", }); if (!ok) return; } window.AppMode.toggle(); } wrapper.addEventListener("click", handleToggle); checkbox.addEventListener("change", () => { // checked = live. Si pasó de dryrun → live, confirmar. if (checkbox.checked && window.AppMode.isDryRun) { // revertir UI temporalmente; confirmAndToggle decide. checkbox.checked = false; confirmAndToggle(); } else if (!checkbox.checked && window.AppMode.isLive) { window.AppMode.set("dryrun"); } }); window.addEventListener("app-mode-changed", syncUI); syncUI(); } // --- INICIALIZACIÓN --- document.addEventListener("DOMContentLoaded", () => { initTabNavigation(); initEventListeners(); initGlobalModeToggle(); initUniversalDryRunToggle(); // Legacy: el toggle del tab Scripts queda como espejo del global. loadBranches().then(() => { selectBranch(activeBranchId); checkGlobalSyncProgress(); // Verificar si ya hay sync corriendo al cargar }); }); // --- SISTEMA DE TABS --- function initTabNavigation() { const tabs = document.querySelectorAll(".tab-link"); updateTabLayoutMode(); tabs.forEach(tab => { tab.addEventListener("click", () => { tabs.forEach(t => t.classList.remove("active")); tab.classList.add("active"); const targetTab = tab.getAttribute("data-tab"); activeTab = targetTab; document.querySelectorAll(".tab-section").forEach(sec => { sec.classList.remove("active"); }); document.getElementById(targetTab).classList.add("active"); updateTabLayoutMode(); // Cargar datos según la pestaña activa refreshActiveView(); }); }); } function updateTabLayoutMode() { const wrapper = document.querySelector(".tab-content-wrapper"); if (!wrapper) return; wrapper.classList.toggle("scripts-mode", activeTab === "tab-scripts"); } function refreshActiveView() { if (activeTab === "tab-dashboard") { loadDashboardMetrics(); } else if (activeTab === "tab-comparativa") { loadComparativaMarcaSucursales(); } else if (activeTab === "tab-contacts") { currentContactsPage = 1; loadContactsTable(); } else if (activeTab === "tab-opportunities") { loadPipelinesAndBoard(); } else if (activeTab === "tab-scripts") { loadScriptsCatalog(); } else if (activeTab === "tab-workflows") { loadWorkflowsTable(); } } // --- EVENT LISTENERS --- function initEventListeners() { // Info de la sucursal activa en el Header const activeInfo = document.querySelector(".active-info"); if (activeInfo) { activeInfo.removeAttribute("title"); } // Buscador de sucursales en Sidebar const searchInput = document.getElementById("branch-search"); searchInput.addEventListener("input", (e) => { filterBranches(e.target.value); }); // Buscador en la tabla Master Global const globalMasterSearch = document.getElementById("global-master-search"); if (globalMasterSearch) { globalMasterSearch.addEventListener("input", (e) => { renderGlobalMasterTable(e.target.value); }); } // Sincronización Global document.getElementById("btn-sync-global").addEventListener("click", triggerGlobalSync); const syncMenuBtn = document.getElementById("btn-sync-menu"); const syncMenu = document.getElementById("sync-menu"); const syncMetadataBtn = document.getElementById("btn-sync-metadata"); if (syncMenuBtn && syncMenu) { syncMenuBtn.addEventListener("click", (event) => { event.stopPropagation(); syncMenu.classList.toggle("hidden"); }); document.addEventListener("click", () => syncMenu.classList.add("hidden")); } if (syncMetadataBtn) { syncMetadataBtn.addEventListener("click", (event) => { event.stopPropagation(); syncMenu.classList.add("hidden"); triggerMetadataSync(); }); } // Sincronización Sucursal Activa document.getElementById("btn-sync-active").addEventListener("click", triggerActiveBranchSync); // Buscador en Tabla de Contactos const contactSearch = document.getElementById("contact-table-search"); contactSearch.addEventListener("input", (e) => { contactsSearchQuery = e.target.value; currentContactsPage = 1; // debounce simple clearTimeout(contactSearch.timeout); contactSearch.timeout = setTimeout(() => { loadContactsTable(); }, 300); }); // Toggle "Solo sin oportunidad" en la pestaña Contactos const withoutOppBtn = document.getElementById("btn-filter-without-opp"); if (withoutOppBtn) { withoutOppBtn.addEventListener("click", () => { if (activeBranchId === "global") return; contactsWithoutOppFilter = !contactsWithoutOppFilter; currentContactsPage = 1; loadContactsTable(); }); } // Botón bulk: crear oportunidades para todos los contactos sin opp. const bulkCreateBtn = document.getElementById("btn-bulk-create-opps"); if (bulkCreateBtn) { bulkCreateBtn.addEventListener("click", () => bulkCreateOpportunities()); } // Paginación de Contactos document.getElementById("btn-page-prev").addEventListener("click", () => { if (currentContactsPage > 1) { currentContactsPage--; loadContactsTable(); } }); document.getElementById("btn-page-next").addEventListener("click", () => { currentContactsPage++; loadContactsTable(); }); // Selector de Pipeline en Kanban document.getElementById("pipeline-select").addEventListener("change", (e) => { activePipelineId = e.target.value; loadKanbanBoard(); }); // Pestañas internas de la terminal (Salida / Errores) initTerminalTabs(); // Limpiar terminal — ahora va por el LiveTerminal (vacía buffer + DOM). document.getElementById("btn-clear-terminal").addEventListener("click", () => { const term = ensureLiveTerminal(); term.reset(); const screen = document.getElementById("terminal-screen"); // Repintar placeholder informativo. const placeholder = document.createElement("div"); placeholder.className = "terminal-placeholder"; placeholder.innerHTML = '

Terminal Limpia

Selecciona un script y presiona ejecutar para ver resultados aquí.'; screen.appendChild(placeholder); // Ocultar bar de run (no hay run activo) const runBar = document.getElementById("terminal-run-bar"); if (runBar) runBar.classList.add("hidden"); const dlBtn = document.getElementById("btn-download-log"); if (dlBtn) dlBtn.classList.add("hidden"); clearTerminalErrors(); setTerminalState("idle", "Listo"); if (window.activeExecutingCard) { setCardState(window.activeExecutingCard, "idle", ` Ejecutar`); window.activeExecutingCard = null; } }); // Badge "Volver al final" — hace scroll al fondo y re-engancha el auto-scroll. const resumeScrollBtn = document.getElementById("terminal-resume-scroll"); if (resumeScrollBtn) { resumeScrollBtn.addEventListener("click", () => { const term = ensureLiveTerminal(); term.scrollToBottom(); resumeScrollBtn.classList.add("hidden"); }); } // Botón "Descargar log completo" — apunta al endpoint persistente del run activo. const downloadLogBtn = document.getElementById("btn-download-log"); if (downloadLogBtn) { downloadLogBtn.addEventListener("click", () => { const taskId = downloadLogBtn.getAttribute("data-task-id"); if (!taskId) { showToast("No hay un run activo del cual descargar el log.", "info"); return; } window.location.href = `/api/scripts/logs/${encodeURIComponent(taskId)}`; }); } // Copiar terminal (respeta la pestaña activa) document.getElementById("btn-copy-terminal").addEventListener("click", () => { const activeTab = getActiveTerminalTab(); const textToCopy = activeTab === "errors" ? buildErrorsCopyText() : buildOutputCopyText(); if (!textToCopy) { showToast(activeTab === "errors" ? "No hay errores para copiar." : "No hay logs para copiar.", "info"); return; } function fallbackCopyText(text) { const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; textArea.style.left = "-9999px"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { const successful = document.execCommand('copy'); if (successful) { showToast("Logs copiados al portapapeles.", "success"); } else { showToast("No se pudo copiar los logs.", "error"); } } catch (err) { showToast("Error al copiar logs.", "error"); } document.body.removeChild(textArea); } if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(textToCopy).then(() => { showToast("Logs copiados al portapapeles.", "success"); }).catch(err => { fallbackCopyText(textToCopy); }); } else { fallbackCopyText(textToCopy); } }); // Filtros de Workflows const workflowSearch = document.getElementById("workflow-table-search"); if (workflowSearch) { workflowSearch.addEventListener("input", () => { renderWorkflowsTable(); }); } const workflowBranchSelect = document.getElementById("workflow-branch-select"); if (workflowBranchSelect) { workflowBranchSelect.addEventListener("change", () => { renderWorkflowsTable(); }); } const workflowStatusSelect = document.getElementById("workflow-status-select"); if (workflowStatusSelect) { workflowStatusSelect.addEventListener("change", () => { renderWorkflowsTable(); }); } const workflowDateField = document.getElementById("workflow-date-field"); if (workflowDateField) { workflowDateField.addEventListener("change", () => renderWorkflowsTable()); } const workflowDateAfter = document.getElementById("workflow-date-after"); if (workflowDateAfter) { workflowDateAfter.addEventListener("change", () => renderWorkflowsTable()); } const workflowDateBefore = document.getElementById("workflow-date-before"); if (workflowDateBefore) { workflowDateBefore.addEventListener("change", () => renderWorkflowsTable()); } const btnClearDates = document.getElementById("btn-workflow-clear-dates"); if (btnClearDates) { btnClearDates.addEventListener("click", () => { if (workflowDateAfter) workflowDateAfter.value = ""; if (workflowDateBefore) workflowDateBefore.value = ""; renderWorkflowsTable(); }); } const btnSyncWorkflows = document.getElementById("btn-sync-workflows"); if (btnSyncWorkflows) { btnSyncWorkflows.addEventListener("click", triggerWorkflowsSync); } const btnExportWorkflows = document.getElementById("btn-export-workflows"); if (btnExportWorkflows) { btnExportWorkflows.addEventListener("click", exportWorkflowsExcel); } const btnRefreshSession = document.getElementById("btn-refresh-session"); if (btnRefreshSession) { btnRefreshSession.addEventListener("click", refreshBucefaloSession); } const btnBulkDraft = document.getElementById("btn-bulk-draft"); if (btnBulkDraft) btnBulkDraft.addEventListener("click", () => triggerBulkToggle("draft")); const btnBulkPublish = document.getElementById("btn-bulk-publish"); if (btnBulkPublish) btnBulkPublish.addEventListener("click", () => triggerBulkToggle("publish")); const btnBulkDelete = document.getElementById("btn-bulk-delete"); if (btnBulkDelete) btnBulkDelete.addEventListener("click", () => triggerBulkToggle("delete")); const btnBulkScanAnomalies = document.getElementById("btn-bulk-scan-anomalies"); if (btnBulkScanAnomalies) btnBulkScanAnomalies.addEventListener("click", triggerBulkScanAnomalies); const btnBulkAnomaliesToggle = document.getElementById("btn-bulk-anomalies-toggle"); if (btnBulkAnomaliesToggle) btnBulkAnomaliesToggle.addEventListener("click", bulkToggleAnomaliesList); const btnBulkCancel = document.getElementById("btn-bulk-cancel"); if (btnBulkCancel) btnBulkCancel.addEventListener("click", cancelBulkScan); const btnBulkClear = document.getElementById("btn-bulk-clear"); if (btnBulkClear) btnBulkClear.addEventListener("click", clearWorkflowSelection); const btnBulkCloseProgress = document.getElementById("btn-bulk-close-progress"); if (btnBulkCloseProgress) btnBulkCloseProgress.addEventListener("click", bulkExitProgressMode); const btnBulkRetryPending = document.getElementById("btn-bulk-retry-pending"); if (btnBulkRetryPending) btnBulkRetryPending.addEventListener("click", retryBulkPending); const selectAll = document.getElementById("workflows-select-all"); if (selectAll) selectAll.addEventListener("change", toggleWorkflowsSelectAll); const scriptSearchInput = document.getElementById("script-search-input"); if (scriptSearchInput) { scriptSearchInput.addEventListener("input", (e) => { scriptsSearchQuery = e.target.value.trim().toLowerCase(); renderScriptsByFilter(activeScriptsCategory); }); } } function initUniversalDryRunToggle() { // El toggle de la terminal de Scripts ya NO es un control independiente: // es un espejo del estado global (window.AppMode) y reflejo visual. // Toda interacción real ocurre desde el toggle del header global. const checkbox = document.getElementById("universal-dryrun-checkbox"); const wrapper = document.getElementById("universal-dryrun-wrapper"); if (!checkbox || !wrapper) return; // Reemplaza la etiqueta para dejar claro que es un reflejo, no un control aparte. const label = wrapper.querySelector(".toggle-label"); if (label) label.textContent = "Modo (global)"; function sync() { const isDryRun = window.AppMode.isDryRun; checkbox.checked = isDryRun; // mantenemos su semántica original: checked = dry-run wrapper.classList.toggle("is-active", isDryRun); wrapper.classList.toggle("is-danger", !isDryRun); wrapper.title = isDryRun ? "Modo Simulación (controlado desde el toggle global del header)." : "MODO LIVE — los scripts ejecutan cambios reales en Bucéfalo."; } // El checkbox interno no edita el estado: redirige al toggle global. checkbox.addEventListener("change", (e) => { // Cualquier cambio aquí redirige al toggle global (que pide confirmación si va a live). e.preventDefault(); const desiredDryRun = checkbox.checked; if (desiredDryRun && window.AppMode.isLive) { window.AppMode.set("dryrun"); } else if (!desiredDryRun && window.AppMode.isDryRun) { // Disparar la confirmación del global haciendo click en el header. document.getElementById("global-mode-toggle")?.click(); // restaurar visual hasta que el global decida. sync(); } }); window.addEventListener("app-mode-changed", sync); sync(); } // --- NOTIFICACIONES TOAST --- function showToast(message, type = "info") { const container = document.getElementById("toast-container"); const toast = document.createElement("div"); toast.className = `toast ${type}`; let icon = "info-circle"; if (type === "success") icon = "check-circle"; if (type === "error") icon = "triangle-exclamation"; toast.innerHTML = ` ${message} `; container.appendChild(toast); // Auto-eliminar setTimeout(() => { toast.style.animation = "slideIn 0.3s ease reverse"; setTimeout(() => { toast.remove(); }, 300); }, 4000); } // --- MODALES CUSTOM (appConfirm / appPrompt) --- // Reemplazan los pop-ups nativos del browser (confirm/prompt/alert). // Promise-based: `const ok = await appConfirm({...});` function _createAppModal({ title, severity = 'info', bodyHTML, footerHTML }) { const iconByService = { danger: 'triangle-exclamation', warning: 'circle-exclamation', info: 'circle-info', }; const icon = iconByService[severity] || 'circle-info'; const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop'; backdrop.innerHTML = ` `; document.body.appendChild(backdrop); return backdrop; } function appConfirm({ title, message, confirmText = 'Confirmar', cancelText = 'Cancelar', severity = 'info' }) { return new Promise((resolve) => { const confirmBtnClass = severity === 'danger' ? 'btn-danger' : 'btn-primary'; const modal = _createAppModal({ title, severity, bodyHTML: `

${message}

`, footerHTML: ` `, }); const close = (val) => { modal.remove(); document.removeEventListener('keydown', onKey); resolve(val); }; const onKey = (e) => { if (e.key === 'Escape') close(false); else if (e.key === 'Enter') close(true); }; modal.addEventListener('click', (e) => { if (e.target === modal) return close(false); const act = e.target.closest('[data-act]')?.dataset.act; if (act === 'ok') close(true); else if (act === 'cancel') close(false); }); document.addEventListener('keydown', onKey); // Foco en el botón de confirmar para que Enter dispare confirma. setTimeout(() => modal.querySelector('[data-act="ok"]')?.focus(), 50); }); } /** * Modal de selección: el usuario elige un ítem de una lista (radio buttons). * Devuelve Promise con el índice del seleccionado (0-based), o null si canceló. * * options: [{ label: string, sublabel?: string }] */ function appSelectFromList({ title, message, options, confirmText = 'Aceptar', cancelText = 'Cancelar', severity = 'info' }) { return new Promise((resolve) => { const optionsHTML = options.map((opt, i) => ` `).join(''); const modal = _createAppModal({ title, severity, bodyHTML: `

${message}

${optionsHTML}
`, footerHTML: ` `, }); const close = (val) => { modal.remove(); document.removeEventListener('keydown', onKey); resolve(val); }; const onKey = (e) => { if (e.key === 'Escape') close(null); else if (e.key === 'Enter') { const sel = modal.querySelector('input[name="app-modal-select"]:checked'); close(sel ? parseInt(sel.value, 10) : null); } }; modal.addEventListener('click', (e) => { if (e.target === modal) return close(null); const act = e.target.closest('[data-act]')?.dataset.act; if (act === 'ok') { const sel = modal.querySelector('input[name="app-modal-select"]:checked'); close(sel ? parseInt(sel.value, 10) : null); } else if (act === 'cancel') { close(null); } }); document.addEventListener('keydown', onKey); setTimeout(() => modal.querySelector('[data-act="ok"]')?.focus(), 50); }); } function appPrompt({ title, message, expectedAnswer, placeholder = '', severity = 'danger' }) { return new Promise((resolve) => { const modal = _createAppModal({ title, severity, bodyHTML: `

${message}

`, footerHTML: ` `, }); const input = modal.querySelector('.app-modal-prompt-input'); const okBtn = modal.querySelector('[data-act="ok"]'); input.addEventListener('input', () => { okBtn.disabled = input.value !== expectedAnswer; }); setTimeout(() => input.focus(), 50); const close = (val) => { modal.remove(); document.removeEventListener('keydown', onKey); resolve(val); }; const onKey = (e) => { if (e.key === 'Escape') close(false); else if (e.key === 'Enter' && !okBtn.disabled) close(true); }; modal.addEventListener('click', (e) => { if (e.target === modal) return close(false); const act = e.target.closest('[data-act]')?.dataset.act; if (act === 'ok' && !okBtn.disabled) close(true); else if (act === 'cancel') close(false); }); document.addEventListener('keydown', onKey); }); } /** * Modal de progreso para tareas largas (renovar sesión, etc.). * Devuelve un controlador con métodos para actualizar el estado. * * @returns {{ * setStatus: (title: string, detail?: string, logLine?: string) => void, * setSuccess: (title: string, detail?: string, autoCloseMs?: number) => void, * setError: (title: string, detail?: string) => void, * close: () => void, * }} */ function appProgressModal({ title, initialDetail = '', severity = 'info' }) { const modal = _createAppModal({ title, severity, bodyHTML: `
${initialDetail || 'Iniciando…'}
`, footerHTML: ``, }); const iconEl = modal.querySelector('[data-icon]'); const titleEl = modal.querySelector('[data-title]'); const detailEl = modal.querySelector('[data-detail]'); const logEl = modal.querySelector('[data-log]'); const closeBtn = modal.querySelector('[data-act="close"]'); let closed = false; let onCloseHook = null; const close = () => { if (closed) return; closed = true; modal.remove(); document.removeEventListener('keydown', onKey); if (typeof onCloseHook === 'function') onCloseHook(); }; const onKey = (e) => { // Solo permitir cerrar con ESC si el proceso terminó (botón visible). if (e.key === 'Escape' && closeBtn.style.display !== 'none') close(); }; document.addEventListener('keydown', onKey); modal.addEventListener('click', (e) => { if (e.target.closest('[data-act="close"]')) close(); // Click en backdrop: solo cerrar si proceso terminó. if (e.target === modal && closeBtn.style.display !== 'none') close(); }); // Mientras corre, el botón × del header tampoco cierra arbitrariamente. modal.querySelector('.btn-close-modal')?.addEventListener('click', (e) => { if (closeBtn.style.display === 'none') { e.stopPropagation(); } }); return { setStatus(title, detail, logLine) { if (closed) return; iconEl.className = 'app-progress-icon spinning'; iconEl.innerHTML = ''; titleEl.textContent = title; if (detail !== undefined) detailEl.textContent = detail; if (logLine !== undefined) logEl.textContent = logLine; }, setSuccess(title, detail, autoCloseMs = 2500) { if (closed) return; iconEl.className = 'app-progress-icon success'; iconEl.innerHTML = ''; titleEl.textContent = title; detailEl.textContent = detail || ''; logEl.textContent = ''; closeBtn.style.display = ''; if (autoCloseMs > 0) setTimeout(close, autoCloseMs); }, setError(title, detail) { if (closed) return; iconEl.className = 'app-progress-icon failure'; iconEl.innerHTML = ''; titleEl.textContent = title; detailEl.textContent = detail || ''; logEl.textContent = ''; closeBtn.style.display = ''; }, close, onClose(fn) { onCloseHook = fn; }, }; } // --- SELECTOR Y FILTRADO DE SUCURSALES --- async function loadBranches() { try { const response = await fetch("/api/branches"); branchesData = await response.json(); renderBranchesSidebar(); } catch (e) { showToast("Error cargando el catálogo de sucursales.", "error"); } } function renderBranchesSidebar() { const globalContainer = document.getElementById("global-overview-container"); const brandContainer = document.getElementById("brand-account-container"); const branchesContainer = document.getElementById("branches-container"); if (globalContainer) globalContainer.innerHTML = ""; brandContainer.innerHTML = ""; branchesContainer.innerHTML = ""; const brandAcc = branchesData.find(b => b.type === "brand"); const branches = branchesData.filter(b => b.type === "branch"); // Renderizar Consolidado Global if (globalContainer) { let totalContacts = 0; let totalOpps = 0; branches.forEach(b => { totalContacts += b.metrics ? b.metrics.contacts_count : 0; totalOpps += b.metrics ? b.metrics.opps_count : 0; }); const div = document.createElement("div"); div.className = `branch-item ${activeBranchId === "global" ? "active" : ""}`; div.setAttribute("data-id", "global"); div.innerHTML = `
Consolidado Global
Todas las sucursales
${totalContacts}
${totalOpps}
`; div.addEventListener("click", () => selectBranch("global")); globalContainer.appendChild(div); } // Renderizar Marca if (brandAcc) { const item = createBranchDOMItem(brandAcc); brandContainer.appendChild(item); } // Renderizar Sucursales branches.forEach(b => { const item = createBranchDOMItem(b); branchesContainer.appendChild(item); }); } function createBranchDOMItem(b) { const div = document.createElement("div"); div.className = `branch-item ${b.location_id === activeBranchId ? "active" : ""}`; div.setAttribute("data-id", b.location_id); const contactsCount = b.metrics ? b.metrics.contacts_count : 0; const oppsCount = b.metrics ? b.metrics.opps_count : 0; // Quitar prefijo numérico para sidebar let displayName = b.nombre; if (b.type === "branch") { displayName = b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, ""); } div.innerHTML = `
${displayName}
${b.location_id.substring(0, 8)}...
${contactsCount}
${oppsCount}
`; div.addEventListener("click", () => selectBranch(b.location_id)); return div; } function selectBranch(locationId) { // Al cambiar de sucursal se resetea el filtro "sin oportunidad" porque // es un estado contextual al branch que estabas viendo. Las navegaciones // que quieran preservarlo (p.ej. openBranchWithoutOppView) lo re-activan // explícitamente DESPUÉS de llamar a selectBranch. if (activeBranchId !== locationId) { contactsWithoutOppFilter = false; } activeBranchId = locationId; // Actualizar clase activa en Sidebar document.querySelectorAll(".branch-item").forEach(item => { item.classList.remove("active"); if (item.getAttribute("data-id") === locationId) { item.classList.add("active"); } }); // Obtener datos de la sucursal seleccionada if (locationId === "global") { document.getElementById("active-branch-name").innerText = "Consolidado Global"; document.getElementById("active-branch-owner").innerHTML = ` Resumen de las 49 sucursales | Vista Consolidada `; document.getElementById("btn-sync-active").classList.add("hidden"); } else { const b = branchesData.find(x => x.location_id === locationId); if (b) { const ghlUrl = `https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(b.location_id)}/dashboard`; document.getElementById("active-branch-name").innerHTML = `${b.nombre}`; document.getElementById("active-branch-owner").innerHTML = ` ${b.company_owner} | Location: ${b.location_id} `; } document.getElementById("btn-sync-active").classList.remove("hidden"); } updateActiveInfoSummary(); // Reset pipeline activo activePipelineId = ""; // Refrescar la pestaña actual refreshActiveView(); } function filterBranches(query) { const q = query.toLowerCase().strip ? query.toLowerCase().strip() : query.toLowerCase(); document.querySelectorAll("#branches-container .branch-item").forEach(item => { const id = item.getAttribute("data-id"); const b = branchesData.find(x => x.location_id === id); if (b) { const name = b.nombre.toLowerCase(); if (name.includes(q) || id.toLowerCase().includes(q)) { item.style.display = "flex"; } else { item.style.display = "none"; } } }); } // --- 1. DASHBOARD CONTROLLER --- function loadDashboardMetrics() { const detailsGrid = document.getElementById("dashboard-details-grid"); const globalDetails = document.getElementById("global-dashboard-details"); if (activeBranchId === "global") { // Toggle view containers if (detailsGrid) detailsGrid.classList.add("hidden"); if (globalDetails) globalDetails.classList.remove("hidden"); // Calcular métricas globales consolidadas let totalContacts = 0; let wonValue = 0; let wonCount = 0; let openValue = 0; let openCount = 0; let lostValue = 0; let lostCount = 0; branchesData.forEach(b => { if (b.metrics) { totalContacts += b.metrics.contacts_count || 0; const won = b.metrics.opps_by_status?.won; wonValue += won ? (won.value || 0) : 0; wonCount += won ? (won.count || 0) : 0; const open = b.metrics.opps_by_status?.open; openValue += open ? (open.value || 0) : 0; openCount += open ? (open.count || 0) : 0; const lost = b.metrics.opps_by_status?.lost; lostValue += lost ? (lost.value || 0) : 0; lostCount += lost ? (lost.count || 0) : 0; } }); // Rellenar métricas superiores document.getElementById("dash-contacts-count").innerText = totalContacts; document.getElementById("dash-won-value").innerText = formatCurrency(wonValue); document.getElementById("dash-won-count").innerText = `${wonCount} ganadas`; document.getElementById("dash-open-value").innerText = formatCurrency(openValue); document.getElementById("dash-open-count").innerText = `${openCount} abiertas`; document.getElementById("dash-lost-value").innerText = formatCurrency(lostValue); document.getElementById("dash-lost-count").innerText = `${lostCount} perdidas`; // Renderizar la tabla Master Global renderGlobalMasterTable(document.getElementById("global-master-search")?.value || ""); } else { // Vista de sucursal individual if (detailsGrid) detailsGrid.classList.remove("hidden"); if (globalDetails) globalDetails.classList.add("hidden"); const b = branchesData.find(x => x.location_id === activeBranchId); if (!b || !b.metrics) return; const m = b.metrics; // Rellenar métricas superiores document.getElementById("dash-contacts-count").innerText = m.contacts_count; const won = m.opps_by_status.won; document.getElementById("dash-won-value").innerText = formatCurrency(won.value); document.getElementById("dash-won-count").innerText = `${won.count} ganadas`; const open = m.opps_by_status.open; document.getElementById("dash-open-value").innerText = formatCurrency(open.value); document.getElementById("dash-open-count").innerText = `${open.count} abiertas`; const lost = m.opps_by_status.lost; document.getElementById("dash-lost-value").innerText = formatCurrency(lost.value); document.getElementById("dash-lost-count").innerText = `${lost.count} perdidas`; // Renderizar Distribución de Estados const distContainer = document.getElementById("status-distribution-container"); if (distContainer) { distContainer.innerHTML = ""; const totalOpps = m.opps_count || 1; // Evitar división por cero const states = [ { label: "Ganadas (Won)", class: "won", count: won.count }, { label: "Abiertas (Open)", class: "open", count: open.count }, { label: "Perdidas (Lost)", class: "lost", count: lost.count }, { label: "Abandonadas (Abandoned)", class: "abandoned", count: m.opps_by_status.abandoned.count } ]; states.forEach(s => { const pct = ((s.count / totalOpps) * 100).toFixed(1); const bar = document.createElement("div"); bar.className = "dist-bar-wrapper"; bar.innerHTML = `
${s.label} ${s.count} (${pct}%)
`; distContainer.appendChild(bar); }); } loadPipelineOverview(); } } function updateActiveInfoSummary(pipelines = null) { const details = document.getElementById("active-branch-details"); if (!details) return; if (activeBranchId === "global") { const branches = branchesData.filter(b => b.type === "branch"); const totalContacts = branches.reduce((sum, b) => sum + (b.metrics?.contacts_count || 0), 0); const totalOpps = branches.reduce((sum, b) => sum + (b.metrics?.opps_count || 0), 0); const totalPipelines = branches.reduce((sum, b) => sum + (b.metrics?.pipelines_count || 0), 0); details.innerHTML = ` ${branches.length} sucursales ${totalContacts} contactos ${totalOpps} oportunidades ${totalPipelines} pipelines `; return; } const branch = branchesData.find(x => x.location_id === activeBranchId); if (!branch) { details.innerHTML = `Cuenta no encontrada`; return; } const metrics = branch.metrics || {}; const pipelineCount = pipelines ? pipelines.length : (metrics.pipelines_count || 0); const stagesCount = pipelines ? pipelines.reduce((sum, pipe) => sum + (pipe.stages?.length || 0), 0) : null; const stagesLabel = stagesCount === null ? "etapas" : `${stagesCount} etapas`; details.innerHTML = ` ${metrics.contacts_count || 0} contactos ${metrics.opps_count || 0} oportunidades ${pipelineCount} pipelines ${stagesLabel} `; } async function loadPipelineOverview() { const container = document.getElementById("pipeline-overview-container"); if (!container) return; if (activeBranchId === "global") { container.innerHTML = `

El overview de pipelines se muestra al seleccionar una sucursal o cuenta específica.

`; return; } const requestedLocationId = activeBranchId; container.innerHTML = `

Cargando pipelines y etapas...

`; try { const [pipelinesResp, oppsResp] = await Promise.all([ fetch(`/api/pipelines/${requestedLocationId}`), fetch(`/api/opportunities/${requestedLocationId}`) ]); if (!pipelinesResp.ok) throw new Error("Error cargando pipelines"); const data = await pipelinesResp.json(); if (requestedLocationId !== activeBranchId) return; const pipelines = data.pipelines || []; updateActiveInfoSummary(pipelines); // Conteo de oportunidades por pipeline y por etapa const oppsByPipeline = {}; const oppsByStage = {}; let totalOpps = 0; if (oppsResp.ok) { const oppsData = await oppsResp.json(); const oppsList = oppsData.opportunities || []; totalOpps = oppsList.length; for (const o of oppsList) { if (o.pipeline_id) { oppsByPipeline[o.pipeline_id] = (oppsByPipeline[o.pipeline_id] || 0) + 1; } if (o.pipeline_stage_id) { oppsByStage[o.pipeline_stage_id] = (oppsByStage[o.pipeline_stage_id] || 0) + 1; } } } if (pipelines.length === 0) { container.innerHTML = `

No hay pipelines registrados para esta cuenta. Sincroniza la cuenta para actualizar el cache local.

`; return; } const stagesTotal = pipelines.reduce((sum, pipe) => sum + (pipe.stages?.length || 0), 0); const pipelineItems = pipelines.map(pipe => { const stages = pipe.stages || []; const pipeOppCount = oppsByPipeline[pipe.id] || 0; const stagesHtml = stages.length > 0 ? stages.map((stage, index) => { const stageOppCount = oppsByStage[stage.id] || 0; return `
${index + 1} ${stage.name || "Etapa sin nombre"} ${stageOppCount}
`; }).join("") : `Sin etapas registradas.`; return `
Pipeline ${pipe.name || "Pipeline sin nombre"} ${pipe.id || "—"}
${pipeOppCount} opps · ${stages.length} etapas
Etapas
${stagesHtml}
`; }).join(""); // Botón "Migrar al pipeline más reciente": visible solo si hay >1 pipeline. // Dispara dedupe_branch_pipelines vía endpoint, respeta toggle global Dry-Run/Live. const dedupeButtonHTML = pipelines.length > 1 ? `
` : ""; container.innerHTML = `
${pipelines.length}pipelines disponibles
${totalOpps}oportunidades totales
${stagesTotal}etapas configuradas
${dedupeButtonHTML} ${pipelineItems} `; const dedupeBtn = document.getElementById("btn-dedupe-pipelines"); if (dedupeBtn) { dedupeBtn.addEventListener("click", () => dedupePipelinesForBranch(dedupeBtn)); } } catch (e) { console.error("Error cargando overview de pipelines", e); container.innerHTML = `

No se pudo cargar el overview de pipelines.

`; } } /** * Dispara la migración de oportunidades del pipeline más antiguo al más reciente * en la sucursal indicada. Flujo: * 1) Pide preview al backend en modo dry-run (sin escribir). * 2) Muestra el plan al usuario en modal interno con appConfirm. * 3) Si aprueba Y el toggle global está en Live: aplica con escritura real. * Si el toggle global está en Dry-Run: solo muestra el plan, no aplica. * 4) Toast con resumen + refresh del overview. */ async function dedupePipelinesForBranch(btn) { const locationId = btn.dataset.locationId; if (!locationId) return; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` Calculando plan…`; try { // 1. Preview en dry-run, sin importar el toggle global (preview es siempre seguro). const previewRes = await fetch(`/api/pipelines/${encodeURIComponent(locationId)}/dedupe`, { method: "POST", headers: { "Content-Type": "application/json", "X-Dry-Run": "1" }, body: "{}", }); const preview = await previewRes.json(); if (!previewRes.ok) { const detail = (preview && preview.detail) || "No se pudo calcular el plan."; showToast(typeof detail === "string" ? detail : JSON.stringify(detail), "error"); return; } const plans = preview.plans || []; if (plans.length === 0) { await appConfirm({ title: "Sin pipelines duplicados", severity: "info", message: preview.message || "Los pipelines de esta sucursal no tienen nombres normalizados coincidentes. Si quieres migrar entre dos pipelines específicos con nombres distintos, hay que usar el script CLI con --from-pipeline y --to-pipeline.", confirmText: "Entendido", cancelText: "Cerrar", }); return; } // 2. Renderizar el plan en modal. const fmtDate = (iso) => iso ? iso.replace("T", " ").substring(0, 16) + " UTC" : "—"; const plansHTML = plans.map((p, idx) => { const mappingHtml = (p.mapping || []).map(m => `
  • ${escapeHtmlAttr(m.from_stage)}${escapeHtmlAttr(m.to_stage)}
  • ` ).join(""); // Breakdown por flujo (puede no venir si el backend es viejo). const plain = p.plain_move_count ?? p.opportunities_count; const mergeObs = p.merge_obsolete_won_count ?? 0; const mergeOff = p.merge_official_won_count ?? 0; const breakdownHtml = (mergeObs + mergeOff > 0) ? `
    Duplicados detectados — resolución automática
    ${plain} opps se moverían directamente (sin duplicado).
    ${mergeObs} opps el obsoleto gana (etapa más avanzada): sus custom fields se copian a la oficial, la oficial se actualiza al stage avanzado, y la obsoleta se elimina.
    ${mergeOff} opps la oficial gana (ya estaba más avanzada): la oficial queda intacta, la obsoleta simplemente se elimina.
    ` : ""; return `
    Plan ${idx + 1}
    Origen (obsoleto): ${escapeHtmlAttr(p.obsolete.name)} ${escapeHtmlAttr(p.obsolete.id)}
    actualizado ${fmtDate(p.obsolete.date_updated)}
    Destino (oficial): ${escapeHtmlAttr(p.official.name)} ${escapeHtmlAttr(p.official.id)}
    actualizado ${fmtDate(p.official.date_updated)}
    Mapeo de etapas: ${escapeHtmlAttr(p.mapping_kind || "n/a")}
    ${mappingHtml ? `` : ""}
    Oportunidades a procesar: ${p.opportunities_count}
    ${breakdownHtml}
    `; }).join(""); const isLive = window.AppMode.isLive; const ok = await appConfirm({ title: isLive ? "Migrar oportunidades (modo LIVE)" : "Plan de migración (modo Simulación)", severity: isLive ? "danger" : "info", message: `

    ${escapeHtmlAttr(preview.message || "")}

    ${plansHTML}
    ${isLive ? `
    Atención: Estás en modo LIVE. Al confirmar, las oportunidades se moverán de verdad en Bucéfalo y se registrará un run_id de auditoría.
    ` : `
    Estás en modo Simulación. Confirmar solo cierra este preview — nada se aplicará. Cambia a Live desde el toggle del header para aplicar.
    ` } `, confirmText: isLive ? "Sí, aplicar en Bucéfalo" : "Entendido", cancelText: "Cancelar", }); if (!ok) return; // 3. Si está en dry-run global, solo cierra el preview. if (!isLive) { showToast(`Modo Simulación: ${preview.totals.opportunities} oportunidad(es) habrían sido movidas.`, "info"); return; } // 4. Live apply. btn.innerHTML = ` Migrando…`; const applyRes = await mutateFetch(`/api/pipelines/${encodeURIComponent(locationId)}/dedupe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }); const apply = await applyRes.json(); if (!applyRes.ok) { const detail = (apply && apply.detail) || "Error al aplicar la migración."; showToast(typeof detail === "string" ? detail : JSON.stringify(detail), "error"); return; } const t = apply.totals || {}; const movedTotal = (t.moved || 0) + (t.merged_obsolete_won || 0) + (t.merged_official_won || 0); const detailParts = []; detailParts.push(`${t.moved || 0} movidas`); if (t.merged_obsolete_won) detailParts.push(`${t.merged_obsolete_won} merge(obs)`); if (t.merged_official_won) detailParts.push(`${t.merged_official_won} merge(of)`); if (t.skipped) detailParts.push(`${t.skipped} saltadas`); if (t.duplicates_in_official) detailParts.push(`${t.duplicates_in_official} DUP`); if (t.failed) detailParts.push(`${t.failed} fallidas`); showToast( `Migración: ${detailParts.join(", ")}. run_id=${apply.run_id || "—"}`, (t.failed || 0) > 0 ? "error" : "success" ); // Refresh overview (los conteos cambian). try { await loadBranches(); } catch (_) {} try { selectBranch(activeBranchId); } catch (_) {} } catch (err) { showToast(`Error de red en dedupe: ${err.message || err}`, "error"); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } async function loadSyncHistory() { try { const tbody = document.getElementById("sync-history-rows"); if (!tbody) return; const response = await fetch("/api/branches"); const allBranches = await response.json(); let logs = []; allBranches.forEach(b => { if (b.metrics && b.metrics.last_sync) { logs.push({ name: b.nombre, ...b.metrics.last_sync }); } }); // Ordenar por fecha de inicio descendente logs.sort((a, b) => new Date(b.started_at) - new Date(a.started_at)); tbody.innerHTML = ""; if (logs.length === 0) { tbody.innerHTML = `No se encontraron sincronizaciones previas.`; return; } logs.slice(0, 6).forEach(log => { const statusClass = log.status === "success" ? "success" : (log.status === "running" ? "warning" : "danger"); const statusLabel = log.status === "success" ? "Éxito" : (log.status === "running" ? "Sincronizando..." : "Falló"); const tr = document.createElement("tr"); tr.innerHTML = ` ${log.name.replace(/^\d+\s*-\s*MP\s*-\s*/, "")} ${log.started_at.replace("T", " ").substring(0, 19)} ${statusLabel} ${log.contacts_synced} ${log.opps_synced} `; tbody.appendChild(tr); }); } catch (e) { console.error("Error cargando historial de sincronización", e); } } // --- 2. CONTACTOS CONTROLLER --- /** * Refresca el estado visual del botón "Solo sin oportunidad" y la línea de status * según `contactsWithoutOppFilter` y la sucursal activa. */ function syncWithoutOppToggle() { const btn = document.getElementById("btn-filter-without-opp"); const status = document.getElementById("contacts-filter-status"); if (!btn) return; const isGlobal = activeBranchId === "global"; btn.disabled = isGlobal; btn.style.opacity = isGlobal ? "0.5" : "1"; const active = contactsWithoutOppFilter && !isGlobal; btn.classList.toggle("btn-primary", active); btn.classList.toggle("btn-secondary", !active); btn.setAttribute("aria-pressed", String(active)); btn.innerHTML = active ? ` Filtrando: sin oportunidad ` : ` Solo sin oportunidad`; // El botón bulk de "Crear oportunidades para todos" solo es visible cuando // está activo el filtro "Solo sin oportunidad" en una sucursal específica // y hay al menos un contacto sin opp en esa sucursal. const bulkBtn = document.getElementById("btn-bulk-create-opps"); if (bulkBtn) { let show = false; let count = 0; if (active) { const b = branchesData.find(x => x.location_id === activeBranchId); count = b?.metrics?.contacts_without_opp_count ?? 0; show = count > 0; } bulkBtn.style.display = show ? "" : "none"; if (show) { bulkBtn.innerHTML = ` Crear oportunidades para los ${count}`; } } if (status) { if (isGlobal) { status.textContent = "Selecciona una sucursal para usar este filtro."; } else if (active) { const b = branchesData.find(x => x.location_id === activeBranchId); const n = b?.metrics?.contacts_without_opp_count ?? 0; status.textContent = `Mostrando ${n} contactos sin oportunidad en esta sucursal.`; } else { status.textContent = ""; } } } // URL base del CRM (Bucefalo) — los contactos abren en una pestaña nueva. const BUCEFALO_CRM_BASE = "https://crm.bucefalocrm.io"; function buildContactCrmUrl(locationId, contactId) { return `${BUCEFALO_CRM_BASE}/v2/location/${encodeURIComponent(locationId)}/contacts/detail/${encodeURIComponent(contactId)}`; } function escapeHtmlAttr(s) { return String(s ?? "") .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">"); } async function loadContactsTable() { const tbody = document.getElementById("contacts-table-rows"); syncWithoutOppToggle(); // El header "Acción" se decide tras conocer si hay contactos test en la pagina. // Mostrar header si: (a) filtro sin-opp activo en sucursal (botón Crear opp), // (b) hay al menos un contacto test en la pagina actual (botón Eliminar). const withoutOppActionApplies = contactsWithoutOppFilter && activeBranchId !== "global"; const actionHeader = document.getElementById("contacts-action-header"); if (activeBranchId === "global") { if (actionHeader) actionHeader.style.display = "none"; tbody.innerHTML = `

    Visualización de Contactos por Sucursal

    Para ver y filtrar contactos locales, selecciona una sucursal específica de Monte Providencia en la barra lateral izquierda.

    `; document.getElementById("pagination-info").innerText = "Mostrando 0 - 0 de 0"; document.getElementById("btn-page-prev").disabled = true; document.getElementById("btn-page-next").disabled = true; return; } const loadingMsg = contactsWithoutOppFilter ? "Cargando contactos sin oportunidad..." : "Cargando contactos locales cacheables..."; tbody.innerHTML = ` ${loadingMsg}`; try { let url = `/api/contacts/${activeBranchId}?q=${encodeURIComponent(contactsSearchQuery)}&page=${currentContactsPage}&limit=100`; if (contactsWithoutOppFilter) url += "&without_opp=true"; const response = await fetch(url); const data = await response.json(); tbody.innerHTML = ""; const contacts = data.contacts || []; // El botón Eliminar va en cada fila, así que la columna Acción aparece // siempre que estemos en una sucursal específica (no global) y haya o vaya // a haber filas. Para placeholder "no se encontraron contactos" igual ocultamos. const showActionCol = contacts.length > 0; if (actionHeader) actionHeader.style.display = showActionCol ? "" : "none"; const colspan = showActionCol ? 10 : 9; if (contacts.length === 0) { const emptyMsg = contactsWithoutOppFilter ? "No hay contactos sin oportunidad en esta sucursal." : "No se encontraron contactos en esta sucursal."; tbody.innerHTML = `${emptyMsg}`; document.getElementById("pagination-info").innerText = "Mostrando 0 - 0 de 0"; document.getElementById("btn-page-prev").disabled = true; document.getElementById("btn-page-next").disabled = true; return; } contacts.forEach(c => { const name = `${c.first_name || ""} ${c.last_name || ""}`.trim(); const displayName = name || "Sin nombre"; const tags = c.tags || []; const tagsHTML = tags.slice(0, 3).map(t => `${escapeHtmlAttr(t)}`).join(""); const tagsRemaining = tags.length > 3 ? `+${tags.length - 3}` : ""; // Nombre como hipervinculo al CRM de Bucefalo (abre en nueva pestana). const crmUrl = buildContactCrmUrl(activeBranchId, c.id); const testReasonsAttr = escapeHtmlAttr((c.test_reasons || []).join(" · ") || (c.is_test ? "Detectado como prueba/test" : "")); const testBadge = c.is_test ? ` PRUEBA` : ""; const nameCellHTML = ` ${escapeHtmlAttr(displayName)} ${testBadge} `; // Celda Acción: el botón Eliminar siempre está presente (toda fila se puede borrar // bajo confirmación). El botón Crear oportunidad solo cuando el filtro sin-opp está activo. const buttonsHTML = []; if (withoutOppActionApplies) { buttonsHTML.push(``); } const deleteTitle = c.is_test ? `Marcado como prueba: ${(c.test_reasons || []).join(" · ") || "—"}` : "Eliminar contacto (definitivo, hace cascade a oportunidades)"; buttonsHTML.push(``); const actionCellHTML = `
    ${buttonsHTML.join("")}
    `; // Fuente nativa de Bucéfalo (source). Si está vacía mostramos placeholder gris. const sourceHTML = c.source ? `${escapeHtmlAttr(c.source)}` : ''; // Info de vehículo (custom fields del contacto). Placeholder gris si falta. const vehCell = v => v ? escapeHtmlAttr(v) : ''; const tr = document.createElement("tr"); if (c.is_test) tr.classList.add("contact-row-test"); tr.innerHTML = ` ${nameCellHTML} ${c.phone || 'N/A'} ${c.email || 'N/A'} ${sourceHTML} ${tagsHTML}${tagsRemaining || (tags.length === 0 ? 'Ninguna' : '')} ${vehCell(c.marca_vehiculo)} ${vehCell(c.version_vehiculo)} ${vehCell(c.ano_vehiculo)} ${c.date_added ? c.date_added.substring(0, 10) : "N/A"} ${actionCellHTML} `; tbody.appendChild(tr); }); if (withoutOppActionApplies) { tbody.querySelectorAll(".btn-create-opp").forEach(btn => { btn.addEventListener("click", () => createOpportunityForContact(btn)); }); } tbody.querySelectorAll(".btn-delete-contact").forEach(btn => { btn.addEventListener("click", () => deleteContact(btn, { isTest: btn.dataset.isTest === "1" })); }); // Paginación lógica simple — total y test_count vienen del backend. const startIdx = (currentContactsPage - 1) * 100 + 1; const endIdx = startIdx + contacts.length - 1; const total = typeof data.total === "number" ? data.total : contacts.length; const testCount = typeof data.test_count === "number" ? data.test_count : 0; const testSuffix = testCount > 0 ? ` · ${testCount} de prueba (al inicio)` : ""; document.getElementById("pagination-info").innerText = `Mostrando ${startIdx} - ${endIdx} de ${total}${testSuffix}`; document.getElementById("btn-page-prev").disabled = currentContactsPage === 1; document.getElementById("btn-page-next").disabled = endIdx >= total; } catch (e) { if (actionHeader) actionHeader.style.display = "none"; tbody.innerHTML = `Error cargando contactos locales. Sincroniza la cuenta si está vacía.`; } } /** * Elimina un contacto via endpoint DELETE /api/comparativa/contact (borra en * Bucéfalo y limpia cache SQLite). La confirmación SIEMPRE pasa por el modal * interno appConfirm — nada de confirm() nativo del navegador — para que las * acciones queden registradas en el flujo de la plataforma. * * Funciona tanto para contactos de prueba (badge PRUEBA) como para contactos * normales. El mensaje se adapta según `isTest`. GHL no permite undelete, así * que es destructivo permanente. */ async function deleteContact(btn, { isTest = false } = {}) { const locationId = btn.dataset.locationId; const contactId = btn.dataset.contactId; const contactName = btn.dataset.contactName || contactId; const reasonsAttr = btn.dataset.testReasons || ""; const reasonsBlock = (isTest && reasonsAttr) ? `
    Motivos detectados: ${reasonsAttr}
    ` : ""; const intro = isTest ? `Vas a eliminar un contacto marcado como prueba/test:` : `Vas a eliminar el contacto:`; const ok = await appConfirm({ title: "Eliminar contacto", severity: "danger", message: ` ${intro}

    ${contactName}
    ${contactId}

    Esta acción borra el contacto en Bucéfalo de forma definitiva (no hay rollback) y elimina sus oportunidades en cascada. ${reasonsBlock} `, confirmText: "Eliminar definitivamente", cancelText: "Cancelar", }); if (!ok) return; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` Eliminando...`; try { const url = `/api/comparativa/contact?contact_id=${encodeURIComponent(contactId)}&location_id=${encodeURIComponent(locationId)}`; const response = await mutateFetch(url, { method: "DELETE" }); const data = await response.json(); if (!response.ok) { const detail = (data && data.detail) || "No se pudo eliminar el contacto."; showToast(`${contactName}: ${detail}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; return; } const oppsRemoved = data.local_opps_removed || 0; showToast( `Contacto "${contactName}" eliminado.${oppsRemoved > 0 ? ` (${oppsRemoved} oportunidades en cascada)` : ""}`, "success" ); // Sacar la fila de la tabla sin recargar todo. const tr = btn.closest("tr"); if (tr) tr.remove(); // Refrescar contadores globales (badge sidebar, etc). try { await loadBranches(); } catch (_) { /* tolerar */ } } catch (err) { showToast(`Error de red al eliminar el contacto: ${err.message || err}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; } } /** * Crea una oportunidad en GHL para un contacto que no tiene ninguna. * Llama al endpoint POST /api/contacts/{locationId}/{contactId}/opportunity. * El backend valida pipeline 'Standar'/8/'En Pausa' y mapea custom fields * (sucursal, tienda, fuente_prospecto, canal_origen, vehiculo) del contacto * a la oportunidad. Si falta cualquiera de los 4 requeridos, aborta y se * muestra el detalle en un toast de error. */ async function createOpportunityForContact(btn) { const locationId = btn.dataset.locationId; const contactId = btn.dataset.contactId; const contactName = btn.dataset.contactName || contactId; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` Creando...`; try { const response = await mutateFetch(`/api/contacts/${encodeURIComponent(locationId)}/${encodeURIComponent(contactId)}/opportunity`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }); const data = await response.json(); if (!response.ok) { // El backend devuelve 409 con detail={ok:false, error, error_code} para // errores de negocio (pipeline inválido, falta campo, contacto ya tiene opp). const detail = data && data.detail; const errMsg = (detail && typeof detail === "object" && detail.error) ? detail.error : (typeof detail === "string" ? detail : "No se pudo crear la oportunidad."); showToast(`${contactName}: ${errMsg}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; return; } const oppId = data.opportunity_id || "(sin id)"; showToast(`Oportunidad creada para ${contactName} en etapa '${data.stage_name}'. ID: ${oppId}`, "success"); // Refrescar la fila visualmente: se va de la tabla "sin oportunidad". const tr = btn.closest("tr"); if (tr) tr.remove(); // Refrescar el contador del header/sidebar para reflejar la baja // del badge "sin oportunidad" en esta sucursal. try { await loadBranches(); } catch (_) { /* tolerar */ } // loadBranches refresca la sidebar, pero el botón bulk, el texto de // status y los chips del header de la cuenta activa leen branchesData // a través de otras funciones — refrescarlas explícitamente para que // el flujo individual quede consistente con el flujo bulk. syncWithoutOppToggle(); updateActiveInfoSummary(); } catch (err) { showToast(`Error de red al crear la oportunidad: ${err.message || err}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; } } /** * Bulk: crea oportunidades para TODOS los contactos sin oportunidad de la * sucursal activa. Llama al endpoint POST /api/contacts/{loc}/bulk-create-opportunities * que internamente itera y registra todo bajo un único run_id en script_audit. * * Confirmación SIEMPRE via appConfirm (modal interno) — nunca confirm() nativo. * El backend puede tardar varios minutos para sucursales grandes; se muestra * spinner en el botón y un toast con el resumen al terminar. */ async function bulkCreateOpportunities() { if (activeBranchId === "global") return; const btn = document.getElementById("btn-bulk-create-opps"); if (!btn) return; const branch = branchesData.find(x => x.location_id === activeBranchId); const branchName = branch?.nombre || activeBranchId; const count = branch?.metrics?.contacts_without_opp_count ?? 0; if (count === 0) { showToast("No hay contactos sin oportunidad en esta sucursal.", "info"); return; } const ok = await appConfirm({ title: "Crear oportunidades en lote", severity: "warning", message: ` Vas a crear oportunidades para los ${count} contactos sin oportunidad de la sucursal ${escapeHtmlAttr(branchName)}.

    Cada oportunidad se crea con: Todos los cambios quedan registrados bajo un mismo run_id en auditoría.

    Importante: a ${count} contactos esto puede tardar varios minutos por el rate limit de Bucéfalo. `, confirmText: `Crear las ${count} oportunidades`, cancelText: "Cancelar", }); if (!ok) return; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` Procesando ${count} contactos…`; try { const response = await mutateFetch( `/api/contacts/${encodeURIComponent(activeBranchId)}/bulk-create-opportunities`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" } ); const data = await response.json(); if (!response.ok) { const detail = (data && data.detail) || "No se pudo procesar el lote."; showToast(`Error en bulk: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; return; } const t = data.totals || {}; const runId = data.run_id || "(sin id)"; // Muestra resumen detallado en un modal interno para que el usuario // pueda copiar el run_id y revisar saltados/fallidos antes de seguir. const skippedList = (data.details || []).filter(d => !d.ok && (d.error_code === "missing_custom_fields" || d.error_code === "contact_has_opportunity")).slice(0, 10); const failedList = (data.details || []).filter(d => !d.ok && d.error_code !== "missing_custom_fields" && d.error_code !== "contact_has_opportunity").slice(0, 10); const renderList = (items, emptyMsg) => items.length ? `` : `
    ${emptyMsg}
    `; await appConfirm({ title: "Resumen de creación en lote", severity: t.failed > 0 ? "warning" : "info", message: `
    Procesados: ${t.processed}
    Creadas: ${t.created}
    Saltadas: ${t.skipped}
    Fallidas: ${t.failed}
    Run de auditoría: ${runId}
    0 ? "open" : ""}> Saltadas (${t.skipped}) ${renderList(skippedList, "Sin saltadas.")}
    0 ? "open" : ""} style="margin-top: 6px;"> Fallidas (${t.failed}) ${renderList(failedList, "Sin fallos.")}
    `, confirmText: "Cerrar", cancelText: "Cancelar", }); showToast( `Bulk listo: ${t.created} creadas, ${t.skipped} saltadas, ${t.failed} fallidas.`, t.failed > 0 ? "error" : "success" ); // Refrescar contadores y la tabla. try { await loadBranches(); } catch (_) {} currentContactsPage = 1; await loadContactsTable(); } catch (err) { showToast(`Error de red en bulk: ${err.message || err}`, "error"); } finally { btn.disabled = false; // El innerHTML real lo refresca syncWithoutOppToggle si sigue visible. btn.innerHTML = originalHTML; } } // --- 3. KANBAN TAB CONTROLLER --- async function loadPipelinesAndBoard() { const select = document.getElementById("pipeline-select"); const board = document.getElementById("kanban-board"); if (activeBranchId === "global") { select.innerHTML = ``; board.innerHTML = `

    Flujo Kanban de Oportunidades por Sucursal

    El tablero Kanban visualiza las etapas de venta de una sucursal individual. Selecciona una sucursal en la barra lateral para continuar.
    `; return; } select.innerHTML = ``; try { const response = await fetch(`/api/pipelines/${activeBranchId}`); const data = await response.json(); select.innerHTML = ""; const pipelines = data.pipelines; if (!pipelines || pipelines.length === 0) { select.innerHTML = ``; renderEmptyBoard(); return; } pipelines.forEach(p => { const opt = document.createElement("option"); opt.value = p.id; opt.innerText = p.name; select.appendChild(opt); }); // Seleccionar primer pipeline por defecto if (!activePipelineId || !pipelines.find(p => p.id === activePipelineId)) { activePipelineId = pipelines[0].id; } select.value = activePipelineId; loadKanbanBoard(); } catch (e) { select.innerHTML = ``; renderEmptyBoard(); } } function renderEmptyBoard() { const board = document.getElementById("kanban-board"); board.innerHTML = `

    No hay pipelines en esta cuenta.

    Prueba sincronizar los datos de GHL o valida que la cuenta tenga pipelines activos.
    `; } async function loadKanbanBoard() { const board = document.getElementById("kanban-board"); board.innerHTML = `
    Cargando tablero Kanban cacheado...
    `; if (!activePipelineId) { renderEmptyBoard(); return; } try { // Cargar pipeline seleccionado const pipeResp = await fetch(`/api/pipelines/${activeBranchId}`); const pipeData = await pipeResp.json(); const pipeline = pipeData.pipelines.find(p => p.id === activePipelineId); // Cargar oportunidades const oppsResp = await fetch(`/api/opportunities/${activeBranchId}?pipeline_id=${activePipelineId}`); const oppsData = await oppsResp.json(); const opportunities = oppsData.opportunities; board.innerHTML = ""; if (!pipeline || !pipeline.stages || pipeline.stages.length === 0) { renderEmptyBoard(); return; } pipeline.stages.forEach(stage => { const col = document.createElement("div"); col.className = "kanban-column"; // Filtrar oportunidades de esta etapa const stageOpps = opportunities.filter(o => o.pipeline_stage_id === stage.id); col.innerHTML = `

    ${stage.name}

    ${stageOpps.length}
    `; const cardWrapper = col.querySelector(".kanban-cards-wrapper"); if (stageOpps.length === 0) { // Column vacía } else { stageOpps.forEach(opp => { const card = document.createElement("div"); card.className = "kanban-card"; card.setAttribute("draggable", "true"); card.setAttribute("data-opp-id", opp.id); const clientName = `${opp.first_name || ""} ${opp.last_name || ""}`.strip ? `${opp.first_name || ""} ${opp.last_name || ""}`.strip() : `${opp.first_name || ""} ${opp.last_name || ""}`; const statusClass = opp.status === "won" ? "badge-success" : (opp.status === "lost" ? "badge-danger" : "badge-primary"); const statusLabel = opp.status ? opp.status.toUpperCase() : "OPEN"; const vehiculoHTML = opp.vehiculo ? `
    ${escapeHtmlAttr(opp.vehiculo)}
    ` : ""; card.innerHTML = `
    ${opp.name || "Sin nombre"}
    ${clientName || "Sin cliente"}
    ${vehiculoHTML} `; cardWrapper.appendChild(card); }); } board.appendChild(col); }); } catch (e) { board.innerHTML = `
    Error renderizando el tablero de oportunidades.
    `; } } // --- 4. SCRIPTS TAB CONTROLLER --- async function loadScriptsCatalog() { const listContainer = document.getElementById("scripts-list"); // Si ya tenemos los datos cargados y los scripts están renderizados, no recargar ni vaciar la lista if (window.allScriptsData && listContainer.querySelector(".script-item-card")) { return; } listContainer.innerHTML = `

    Cargando catálogo de scripts...

    `; try { if (!branchesData || branchesData.length === 0) { await loadBranches(); } const response = await fetch("/api/scripts"); const scripts = await response.json(); window.allScriptsData = scripts; // Guardar global renderScriptsByFilter("all"); } catch (e) { listContainer.innerHTML = `

    Error cargando el catálogo de scripts locales.

    `; } } function renderScriptsByFilter(category) { const listContainer = document.getElementById("scripts-list"); listContainer.innerHTML = ""; activeScriptsCategory = category || "all"; if (!window.allScriptsData) { return; } // Categoría de Botones document.querySelectorAll(".btn-category").forEach(btn => { btn.classList.remove("active"); if (btn.getAttribute("data-category") === activeScriptsCategory) { btn.classList.add("active"); } }); // Añadir listener una sola vez a los botones de categoría if (!window.categoryTabsInitialized) { document.querySelectorAll(".btn-category").forEach(btn => { btn.addEventListener("click", () => { renderScriptsByFilter(btn.getAttribute("data-category")); }); }); window.categoryTabsInitialized = true; } const categoryFiltered = activeScriptsCategory === "all" ? window.allScriptsData : window.allScriptsData.filter(s => s.category === activeScriptsCategory); const filtered = categoryFiltered.filter(scriptMatchesSearch); if (!filtered || filtered.length === 0) { const message = scriptsSearchQuery ? `No se encontraron scripts para "${escapeHtml(scriptsSearchQuery)}".` : "No se encontraron scripts en esta categoría."; listContainer.innerHTML = `

    ${message}

    `; return; } filtered.forEach(s => { const card = document.createElement("div"); card.className = "script-item-card"; const existsClass = s.exists ? "badge-success" : "badge-danger"; const existsText = s.exists ? "Disponible" : "Esqueleto"; const placeholder = s.args_placeholder ? `placeholder="Argumentos extra: ${escapeHtml(s.args_placeholder)}"` : 'style="display:none;"'; const scopeControls = s.supports_locations ? buildScriptScopeControls(s.name) : ""; const customControls = buildScriptCustomControls(s.name); const optionControls = buildScriptOptionControls(s); const advancedArgs = s.args_placeholder ? buildAdvancedArgsInput(s, placeholder) : ""; card.innerHTML = `

    ${escapeHtml(s.title)}

    ${escapeHtml(s.name)}

    ${existsText}

    ${escapeHtml(s.description)}

    ${scopeControls} ${customControls} ${optionControls}
    ${advancedArgs}
    `; initScriptScopeControls(card); initScriptCustomControls(card, s.name); const metadataButton = card.querySelector(".btn-edit-script-metadata"); if (metadataButton) { metadataButton.addEventListener("click", () => openScriptMetadataModal(s.name)); } const deleteButton = card.querySelector(".btn-delete-script"); if (deleteButton) { deleteButton.addEventListener("click", () => deleteScriptFromCatalog(s.name)); } // Agregar click event card.querySelector(".btn-run-script").addEventListener("click", () => { const argsInput = card.querySelector(".script-item-args-input"); let optionFlags = Array.from(card.querySelectorAll(".script-option-toggle input:checked")).map(input => input.value); let manualArgs = argsInput ? argsInput.value.trim() : ""; // Si el script es reconcile_and_sync_opportunities.py, leer la acción seleccionada en la UI if (s.name === "reconcile_and_sync_opportunities.py") { const selectedAction = card.querySelector('input[name="reconcile-action-type"]:checked')?.value; if (selectedAction === "updates-only") { optionFlags.push("--updates-only"); } } if (s.name === "sync_contacts_branch_to_brand.py") { const contactSyncArgs = getSyncContactsBrandArgs(card); if (contactSyncArgs === null) return; manualArgs = [contactSyncArgs, manualArgs].filter(Boolean).join(" "); } // Modo universal: leer del estado global AppMode (no del checkbox legacy). const isDryRunSelected = window.AppMode.isDryRun; if (s.mutator === true) { if (s.dry_run_mode === "dry_run_flag") { if (isDryRunSelected) { // Asegurar que --dry-run esté presente if (!optionFlags.includes("--dry-run") && !manualArgs.includes("--dry-run")) { optionFlags.push("--dry-run"); } } else { // Quitar --dry-run optionFlags = optionFlags.filter(f => f !== "--dry-run"); manualArgs = manualArgs.replace(/\b--dry-run\b/g, "").trim(); } } else if (s.dry_run_mode === "apply_flag") { if (isDryRunSelected) { // Quitar --apply optionFlags = optionFlags.filter(f => f !== "--apply"); manualArgs = manualArgs.replace(/\b--apply\b/g, "").trim(); } else { // Asegurar que --apply esté presente if (!optionFlags.includes("--apply") && !manualArgs.includes("--apply")) { optionFlags.push("--apply"); } } } } const argsVal = [...optionFlags, manualArgs].filter(Boolean).join(" "); const scopePayload = getScriptScopePayload(card, s); if (!scopePayload) return; executeScript(s.name, argsVal, scopePayload, Boolean(s.supports_audit)); }); listContainer.appendChild(card); }); } function scriptMatchesSearch(script) { if (!scriptsSearchQuery) return true; const optionText = (script.options || []) .map(option => `${option.flag || ""} ${option.label || ""} ${option.description || ""}`) .join(" "); const haystack = [ script.title, script.name, script.description, script.category, script.args_placeholder, optionText, ].join(" ").toLowerCase(); return haystack.includes(scriptsSearchQuery); } function getScriptApiName(scriptName) { return encodeURIComponent(scriptName); } async function openScriptMetadataModal(scriptName) { try { const response = await fetch(`/api/scripts/${getScriptApiName(scriptName)}/metadata`); if (!response.ok) throw new Error("No se pudo cargar la metadata del script."); const meta = await response.json(); document.getElementById("script-metadata-name").value = meta.name || scriptName; document.getElementById("script-meta-title").value = meta.title || ""; document.getElementById("script-meta-description").value = meta.description || ""; document.getElementById("script-meta-category").value = meta.category || "Sin registrar"; document.getElementById("script-meta-args").value = meta.args_placeholder || ""; document.getElementById("script-meta-dry-run-mode").value = meta.dry_run_mode || ""; document.getElementById("script-meta-supports-locations").checked = Boolean(meta.supports_locations); document.getElementById("script-meta-mutator").checked = Boolean(meta.mutator); document.getElementById("script-meta-supports-audit").checked = Boolean(meta.supports_audit); document.getElementById("modal-script-metadata").classList.remove("hidden"); } catch (e) { showToast(e.message, "error"); } } function closeScriptMetadataModal() { document.getElementById("modal-script-metadata").classList.add("hidden"); } async function saveScriptMetadata() { const scriptName = document.getElementById("script-metadata-name").value; const metadata = { title: document.getElementById("script-meta-title").value, description: document.getElementById("script-meta-description").value, category: document.getElementById("script-meta-category").value, args_placeholder: document.getElementById("script-meta-args").value, dry_run_mode: document.getElementById("script-meta-dry-run-mode").value, supports_locations: document.getElementById("script-meta-supports-locations").checked, mutator: document.getElementById("script-meta-mutator").checked, supports_audit: document.getElementById("script-meta-supports-audit").checked, }; try { const response = await fetch(`/api/scripts/${getScriptApiName(scriptName)}/metadata`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ metadata }) }); const data = await response.json(); if (!response.ok || !data.success) throw new Error(data.detail || "No se pudo guardar la metadata."); window.allScriptsData = await fetch("/api/scripts").then(r => r.json()); closeScriptMetadataModal(); renderScriptsByFilter(activeScriptsCategory); showToast("Metadata guardada.", "success"); } catch (e) { showToast(e.message, "error"); } } async function deleteScriptFromCatalog(scriptName) { const confirmed = await appConfirm({ title: "Borrar script del catálogo", message: `Vas a borrar el script ${scriptName}.\n\nEsto eliminará el archivo .py si existe y borrará/ocultará su metadata asociada. Esta acción no se puede deshacer desde el UI.`, confirmText: "Borrar", severity: "danger", }); if (!confirmed) return; try { const response = await fetch(`/api/scripts/${getScriptApiName(scriptName)}`, { method: "DELETE" }); const data = await response.json(); if (!response.ok || !data.success) throw new Error(data.detail || "No se pudo borrar el script."); window.allScriptsData = await fetch("/api/scripts").then(r => r.json()); renderScriptsByFilter(activeScriptsCategory); showToast(`Script ${scriptName} borrado del catálogo.`, "success"); } catch (e) { showToast(e.message, "error"); } } function buildScriptOptionControls(script) { if (!script.options || script.options.length === 0) return ""; const visibleOptions = script.options.filter(option => option.flag !== "--dry-run" && option.flag !== "--apply"); if (visibleOptions.length === 0) return ""; const options = visibleOptions.map(option => ` `).join(""); return `
    Opciones
    ${options}
    `; } function buildScriptCustomControls(scriptName) { if (scriptName === "reconcile_and_sync_opportunities.py") { return `
    Acciones Permitidas
    `; } if (scriptName === "sync_contacts_branch_to_brand.py") { return `
    Datos de Contactos
    Las sucursales solo se leen. Los POST/PUT se hacen únicamente en la cuenta de Marca.
    `; } return ""; } function shellQuoteArg(value) { return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } function getPositiveNumberArg(card, selector, flag) { const input = card.querySelector(selector); const rawValue = input ? input.value.trim() : ""; if (!rawValue) return ""; const numberValue = Number(rawValue); if (!Number.isInteger(numberValue) || numberValue < 1) { showToast(`${flag} debe ser un número entero mayor a 0.`, "error"); return null; } return `${flag} ${numberValue}`; } function getSyncContactsBrandArgs(card) { const args = []; const limitArg = getPositiveNumberArg(card, ".script-contact-sync-limit", "--limit"); if (limitArg === null) return null; if (limitArg) args.push(limitArg); const maxArg = getPositiveNumberArg(card, ".script-contact-sync-max", "--max-contacts"); if (maxArg === null) return null; if (maxArg) args.push(maxArg); const fields = card.querySelector(".script-contact-sync-fields")?.value.trim(); if (fields) { args.push(`--fields ${shellQuoteArg(fields)}`); } return args.join(" "); } function initScriptCustomControls(card, scriptName) { if (scriptName === "reconcile_and_sync_opportunities.py") { const choices = card.querySelectorAll(".reconcile-action-choice"); choices.forEach(choice => { choice.addEventListener("click", () => { choices.forEach(c => c.classList.remove("active")); choice.classList.add("active"); const input = choice.querySelector("input"); if (input) { input.checked = true; // Forzar trigger de cambio para GHL styles input.dispatchEvent(new Event("change")); } }); }); } } function buildAdvancedArgsInput(script, placeholder) { return `
    Avanzado
    `; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function getScriptBranchOptions() { return branchesData .filter(b => b.type === "branch") .map(b => { const displayName = b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, ""); return ` `; }) .join(""); } function buildScriptScopeControls(scriptName) { return `
    Alcance
    Las sucursales seleccionadas se ejecutan en paralelo por defecto.
    Modo de ejecución
    `; } function initScriptScopeControls(card) { const scopeRadios = card.querySelectorAll(".script-scope-choice input"); const locationPicker = card.querySelector(".script-location-picker"); const locationIdRow = card.querySelector(".script-location-id-row"); const locationSearch = card.querySelector(".script-location-search"); const checkVisibleBtn = card.querySelector(".btn-check-visible"); const uncheckAllBtn = card.querySelector(".btn-uncheck-all"); if (!scopeRadios.length || !locationPicker) return; scopeRadios.forEach(radio => { radio.addEventListener("change", () => { const scope = card.querySelector(".script-scope-choice input:checked")?.value || "active"; locationPicker.classList.toggle("hidden", scope !== "custom"); locationIdRow?.classList.toggle("hidden", scope !== "location_id"); }); }); locationSearch?.addEventListener("input", () => { const q = locationSearch.value.toLowerCase().trim(); card.querySelectorAll(".script-location-option").forEach(option => { option.classList.toggle("hidden", !option.innerText.toLowerCase().includes(q)); }); }); checkVisibleBtn?.addEventListener("click", () => { card.querySelectorAll(".script-location-option:not(.hidden) input").forEach(input => { input.checked = true; }); }); uncheckAllBtn?.addEventListener("click", () => { card.querySelectorAll(".script-location-option input").forEach(input => { input.checked = false; }); }); } function getScriptScopePayload(card, script) { if (!script.supports_locations) return {}; const scope = card.querySelector(".script-scope-choice input:checked")?.value || "active"; const executionMode = card.querySelector(".script-mode-choice input:checked")?.value || "parallel"; if (scope === "all") { return { all_locations: true, execution_mode: executionMode }; } if (scope === "active") { if (!activeBranchId || activeBranchId === "global") { showToast("Selecciona una sucursal activa o cambia el alcance a varias/todas.", "error"); return null; } return { locations: [activeBranchId], execution_mode: executionMode }; } if (scope === "location_id") { const locationId = card.querySelector(".script-location-id-input")?.value.trim(); if (!locationId) { showToast("Pega un Location ID para ejecutar el script.", "error"); return null; } return { locations: [locationId], execution_mode: executionMode }; } const selected = Array.from(card.querySelectorAll(".script-location-option input:checked")).map(input => input.value); if (selected.length === 0) { showToast("Selecciona al menos una sucursal para ejecutar el script.", "error"); return null; } return { locations: selected, execution_mode: executionMode }; } function setTerminalState(state, label) { const term = document.querySelector(".terminal-card"); const badge = document.getElementById("terminal-status-badge"); if (!term || !badge) return; term.classList.remove("is-running", "is-success", "is-failed", "is-disconnected"); badge.className = "terminal-status-badge " + state; badge.innerText = label; if (state !== "idle") { term.classList.add("is-" + state); } } // --- TERMINAL: TABS, REGISTRO DE ERRORES Y COPIA --- window.terminalErrors = window.terminalErrors || []; window.activeRunContext = window.activeRunContext || null; function initTerminalTabs() { const tabs = document.querySelectorAll("#terminal-tabs .terminal-tab"); if (!tabs.length) return; tabs.forEach(tab => { tab.addEventListener("click", () => { const target = tab.getAttribute("data-term-tab"); switchTerminalTab(target); }); }); } function switchTerminalTab(tabName) { document.querySelectorAll("#terminal-tabs .terminal-tab").forEach(tab => { tab.classList.toggle("active", tab.getAttribute("data-term-tab") === tabName); }); document.querySelectorAll(".terminal-card [data-term-panel]").forEach(panel => { const isActive = panel.getAttribute("data-term-panel") === tabName; panel.classList.toggle("hidden", !isActive); }); } function getActiveTerminalTab() { const active = document.querySelector("#terminal-tabs .terminal-tab.active"); return active ? active.getAttribute("data-term-tab") : "output"; } function buildOutputCopyText() { // El buffer del LiveTerminal es la fuente de verdad. Leer del DOM forzaría // layout sobre cientos de nodos y duplicaría memoria. Caer al DOM solo si // la instancia no existe todavía (ej. nunca se inició un run). if (window.activeLiveTerminal && typeof window.activeLiveTerminal.getCopyText === "function") { return window.activeLiveTerminal.getCopyText(); } const screen = document.getElementById("terminal-screen"); if (!screen) return ""; const lines = Array.from(screen.querySelectorAll(".terminal-line")); if (!lines.length) return ""; return lines.map(l => l.innerText || l.textContent).join("\n"); } function buildErrorsCopyText() { if (!window.terminalErrors.length) return ""; return window.terminalErrors.map(err => { const time = err.timestamp ? `[${err.timestamp}] ` : ""; return `${time}[${err.scriptName || "—"}] (task ${err.taskId || "—"}) ${err.text}`; }).join("\n"); } function recordTerminalError(text) { if (!text) return; const ctx = window.activeRunContext || {}; const entry = { scriptName: ctx.scriptName || "—", taskId: ctx.taskId || "—", text: String(text), timestamp: new Date().toLocaleTimeString("es-MX", { hour12: false }) }; window.terminalErrors.push(entry); appendErrorToPanel(entry); refreshErrorsBadge(); } function appendErrorToPanel(entry) { const list = document.getElementById("terminal-errors-list"); if (!list) return; // Limpiar placeholder en la primera entrada const placeholder = list.querySelector(".terminal-placeholder"); if (placeholder) placeholder.remove(); const item = document.createElement("div"); item.className = "terminal-error-item"; const header = document.createElement("div"); header.className = "terminal-error-item-header"; header.innerHTML = ` ${escapeHtml(entry.scriptName)} ${escapeHtml(entry.taskId)} ${escapeHtml(entry.timestamp)} `; const body = document.createElement("div"); body.className = "terminal-error-item-body"; body.textContent = entry.text; item.appendChild(header); item.appendChild(body); list.appendChild(item); // Mantener el panel acotado para no saturar el DOM while (list.querySelectorAll(".terminal-error-item").length > 500) { const first = list.querySelector(".terminal-error-item"); if (first) list.removeChild(first); else break; } list.scrollTop = list.scrollHeight; } function refreshErrorsBadge() { const count = window.terminalErrors.length; const countBadge = document.getElementById("terminal-error-count"); const total = document.getElementById("terminal-errors-total"); const breakdown = document.getElementById("terminal-errors-breakdown"); const tabBtn = document.querySelector('#terminal-tabs .terminal-tab[data-term-tab="errors"]'); if (countBadge) countBadge.textContent = count; if (total) total.textContent = count; if (tabBtn) tabBtn.classList.toggle("has-errors", count > 0); if (breakdown) { if (!count) { breakdown.textContent = "Sin scripts con fallos."; } else { const byScript = window.terminalErrors.reduce((acc, e) => { const key = e.scriptName || "—"; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); const parts = Object.entries(byScript) .sort((a, b) => b[1] - a[1]) .map(([name, n]) => `${name}: ${n}`); breakdown.textContent = parts.join(" · "); } } } function clearTerminalErrors() { window.terminalErrors = []; const list = document.getElementById("terminal-errors-list"); if (list) { list.innerHTML = `

    Sin errores registrados

    Los errores detectados durante la ejecución aparecerán aquí con su script, Task ID y log.
    `; } refreshErrorsBadge(); } function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function setCardState(card, state, btnLabel) { if (!card) return; card.classList.remove("is-running", "is-success", "is-failed"); const btn = card.querySelector(".btn-run-script"); if (btn) { btn.disabled = (state === "running"); btn.innerHTML = btnLabel; } if (state !== "idle") { card.classList.add("is-" + state); } } // --------------------------------------------------------------------------- // LiveTerminal singleton — terminal de scripts. Se crea una vez y se reutiliza // entre runs (un reset() limpia entre ejecuciones). El módulo live_terminal.js // se incluye antes que app.js, así window.LiveTerminal siempre existe. // --------------------------------------------------------------------------- function ensureLiveTerminal() { if (window.activeLiveTerminal) return window.activeLiveTerminal; const screen = document.getElementById("terminal-screen"); if (!screen || !window.LiveTerminal) return null; const term = new window.LiveTerminal(screen, { maxLines: 3000, onError: (text) => recordTerminalError(text), onStats: (stats) => updateRunCounter(stats), // onEof y onStreamError los seteamos dinámicamente en attachStream // según el run activo (ver executeScript). }); // El badge "Volver al final" se muestra/oculta según userScrolledUp. // Como el flush ya hace el toggle, lo seguimos con un listener adicional // de scroll (passive) para reflejar al instante el estado del usuario. screen.addEventListener("scroll", () => { const badge = document.getElementById("terminal-resume-scroll"); if (!badge) return; const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 40; if (atBottom) badge.classList.add("hidden"); else if (window.activeEventSource) badge.classList.remove("hidden"); }, { passive: true }); window.activeLiveTerminal = term; return term; } function updateRunCounter(stats) { const el = document.getElementById("terminal-run-counter"); if (!el || !stats) return; const fmt = (n) => n.toLocaleString("es-MX"); if (stats.truncated > 0) { el.innerHTML = `${fmt(stats.visible)} líneas · ${fmt(stats.truncated)} truncadas`; } else { el.textContent = `${fmt(stats.visible)} líneas`; } } function setRunBarVisible(visible, taskId) { const bar = document.getElementById("terminal-run-bar"); if (!bar) return; if (visible) { bar.classList.remove("hidden"); const dl = document.getElementById("btn-download-log"); if (dl) { dl.setAttribute("data-task-id", taskId || ""); if (taskId) dl.classList.remove("hidden"); } } else { bar.classList.add("hidden"); const dl = document.getElementById("btn-download-log"); if (dl) { dl.setAttribute("data-task-id", ""); dl.classList.add("hidden"); } } } // Monta los botones de Pausar/Reanudar/Detener/Revertir en el header sticky, // fuera del screen del terminal. Así nunca se truncan con el cap del buffer. function mountRunControls(taskId, supportsAudit) { const container = document.getElementById("terminal-run-controls"); if (!container) return; // Limpiar controles previos sin innerHTML masivo while (container.firstChild) container.removeChild(container.firstChild); container.setAttribute("data-task-id", taskId || ""); if (!supportsAudit || !taskId) return; const makeBtn = (cls, label, variant) => { const b = document.createElement("button"); b.className = `btn btn-${variant || "secondary"} btn-sm ${cls}`; b.textContent = label; return b; }; const pause = makeBtn("btn-run-pause", "Pausar"); const resume = makeBtn("btn-run-resume", "Reanudar"); const stop = makeBtn("btn-run-stop", "Detener seguro"); const rollback = makeBtn("btn-run-rollback", "Revertir cambios", "primary"); pause.addEventListener("click", () => controlRun(taskId, "pause")); resume.addEventListener("click", () => controlRun(taskId, "resume")); stop.addEventListener("click", () => controlRun(taskId, "stop")); rollback.addEventListener("click", async () => { const ok = await appConfirm({ title: "Revertir ejecución", message: "Esto revertirá los cambios aplicados por esta ejecución (donde sea posible). Algunas operaciones pueden no ser reversibles.", confirmText: "Revertir", severity: "warning", }); if (!ok) return; await controlRun(taskId, "rollback"); }); container.appendChild(pause); container.appendChild(resume); container.appendChild(stop); container.appendChild(rollback); } async function executeScript(scriptName, argumentsVal, scopePayload = {}, supportsAudit = false) { const locationCount = scopePayload.locations ? scopePayload.locations.length : 0; const scopeLabel = scopePayload.all_locations ? "todas las sucursales" : (locationCount > 1 ? `${locationCount} sucursales` : (locationCount === 1 ? "1 sucursal" : "alcance por argumentos")); const term = ensureLiveTerminal(); if (!term) { showToast("No se pudo inicializar la terminal en vivo.", "error"); return; } // Cerrar EventSource previo activo y limpiar buffer para el nuevo run. if (window.activeEventSource) { try { window.activeEventSource.close(); } catch (e) {} window.activeEventSource = null; } term.detach(); term.reset(); setRunBarVisible(false); const btn = document.querySelector(`.btn-run-script[data-name="${scriptName}"]`); const card = btn ? btn.closest(".script-item-card") : null; if (window.activeExecutingCard && window.activeExecutingCard !== card) { setCardState(window.activeExecutingCard, "idle", ` Ejecutar`); } window.activeExecutingCard = card; setTerminalState("running", "Ejecutando"); setCardState(card, "running", ` Ejecutando...`); // Contexto activo para que el panel de errores pueda etiquetar cada línea window.activeRunContext = { scriptName: scriptName, taskId: null }; term.appendSystem(`[SISTEMA]: Inicializando subproceso para ${scriptName} (${scopeLabel})...`, "info"); const sMeta = window.allScriptsData ? window.allScriptsData.find(s => s.name === scriptName) : null; if (sMeta && sMeta.mutator === true) { if (window.AppMode.isDryRun) { term.appendSystem("[SISTEMA]: MODO SIMULACIÓN (DRY-RUN) ACTIVO — Ningún cambio será guardado en Bucéfalo.", "info"); } else { term.appendSystem("[SISTEMA ¡ATENCIÓN!]: MODO LIVE ACTIVO — Los cambios se aplicarán de verdad en Bucéfalo.", "error"); } } try { const response = await mutateFetch(`/api/scripts/${scriptName}/run`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ arguments: argumentsVal, locations: scopePayload.locations || [], all_locations: Boolean(scopePayload.all_locations), execution_mode: scopePayload.execution_mode || "sequential" }) }); const data = await response.json(); if (!data.success) { term.appendSystem(`[SISTEMA ERROR]: No se pudo iniciar. ${data.message}`, "error"); setTerminalState("failed", "Falló"); setCardState(card, "failed", ` Error inicio`); return; } const taskId = data.task_id; if (window.activeRunContext) window.activeRunContext.taskId = taskId; term.appendSystem(`[SISTEMA]: Script iniciado. Conectando logs vía SSE (Task ID: ${taskId})...`, "info"); // Mostrar el bar sticky con contador + controles + botón descargar setRunBarVisible(true, taskId); mountRunControls(taskId, supportsAudit); let streamCompleted = false; let streamErrorCount = 0; const finishWithStatus = async () => { if (streamCompleted) return; streamCompleted = true; loadSyncHistory(); // Pequeña espera para que el backend cierre el run en su tabla. setTimeout(async () => { let isSuccess = true; try { const statusResponse = await fetch(`/api/scripts/status/${taskId}`); if (statusResponse.ok) { const status = await statusResponse.json(); isSuccess = (status.status === "success"); } } catch (e) { /* fallback */ } if (isSuccess) { setTerminalState("success", "Completado"); setCardState(card, "success", ` Completado`); showToast(`Script ${scriptName} finalizado con éxito.`, "success"); } else { setTerminalState("failed", "Falló"); setCardState(card, "failed", ` Falló`); showToast(`Script ${scriptName} finalizado con errores.`, "error"); } }, 100); }; // attachStream se encarga del EventSource, batching y rendering. const eventSource = term.attachStream(taskId); window.activeEventSource = eventSource; // onEof reasignado para este run específico. term.onEof = () => { term.appendSystem("[SISTEMA]: Logs cerrados. Ejecución finalizada.", "info"); window.activeEventSource = null; finishWithStatus(); }; // onStreamError: reintentar mientras EventSource está reconectando; // si el backend ya marcó success/failed, cerramos el run. term.onStreamError = async () => { if (streamCompleted || eventSource.readyState === EventSource.CLOSED) return; streamErrorCount += 1; try { const statusResponse = await fetch(`/api/scripts/status/${taskId}`); if (statusResponse.ok) { const status = await statusResponse.json(); if (status.status === "success" || status.status === "failed") { const lineClass = status.status === "success" ? "success" : "error"; term.appendSystem(`[SISTEMA]: Stream cerrado. Estado final: ${status.status}.`, lineClass); term.detach(); window.activeEventSource = null; streamCompleted = true; if (status.status === "success") { setTerminalState("success", "Completado"); setCardState(card, "success", ` Completado`); showToast(`Script ${scriptName} finalizado con éxito.`, "success"); } else { setTerminalState("failed", "Falló"); setCardState(card, "failed", ` Falló`); showToast(`Script ${scriptName} finalizado con errores.`, "error"); } return; } } } catch (e) { /* dejar reintentar */ } if (streamErrorCount < 4) return; term.detach(); window.activeEventSource = null; term.appendSystem("[SISTEMA ERROR]: Conexión con EventSource perdida de forma inesperada.", "error"); setTerminalState("disconnected", "Desconectado"); setCardState(card, "failed", ` Conexión perdida`); }; } catch (e) { term.appendSystem("[SISTEMA ERROR]: Fallo de conexión con la API del servidor.", "error"); showToast("Error de red intentando ejecutar script.", "error"); setTerminalState("failed", "Falló"); setCardState(card, "failed", ` Error red`); } } async function controlRun(taskId, action) { const term = ensureLiveTerminal(); try { const response = await mutateFetch(`/api/scripts/runs/${taskId}/${action}`, { method: "POST" }); const data = await response.json(); if (!response.ok || data.success === false) { throw new Error(data.detail || data.message || "No se pudo aplicar la acción"); } if (term) term.appendSystem(`[SISTEMA]: ${data.message || action + " solicitado"}`, "info"); showToast(data.message || `${action} solicitado`, "success"); } catch (e) { if (term) term.appendSystem(`[SISTEMA ERROR]: ${e.message}`, "error"); showToast(e.message, "error"); } } // --- CONTROLES DE SINCRONIZACIÓN DE LA API --- async function triggerActiveBranchSync() { const btn = document.getElementById("btn-sync-active"); const originalText = btn.innerHTML; btn.innerHTML = ` Sincronizando...`; btn.disabled = true; showToast("Iniciando sincronización de sucursal en segundo plano...", "info"); try { const response = await fetch(`/api/sync/${activeBranchId}`, { method: "POST" }); const data = await response.json(); showToast(data.message, "success"); // Encender polling corto local para esta sucursal (por simplicidad, recargamos en 10s o cuando veamos cambios) setTimeout(async () => { await loadBranches(); selectBranch(activeBranchId); showToast("Datos de la sucursal actualizados con éxito.", "success"); btn.innerHTML = originalText; btn.disabled = false; }, 12000); } catch (e) { showToast("No se pudo iniciar la sincronización de la sucursal.", "error"); btn.innerHTML = originalText; btn.disabled = false; } } async function triggerGlobalSync() { if (isSyncingGlobally) return; try { const response = await fetch("/api/sync", { method: "POST" }); const data = await response.json(); if (data.error) { showToast(data.error, "error"); return; } showToast(data.message, "success"); startGlobalSyncPolling(); } catch (e) { showToast("Error iniciando sincronización global.", "error"); } } async function triggerMetadataSync() { if (isSyncingGlobally) return; try { const response = await fetch("/api/sync/metadata", { method: "POST" }); const data = await response.json(); if (data.error) { showToast(data.error, "error"); return; } showToast(data.message, "success"); startGlobalSyncPolling(); } catch (e) { showToast("Error iniciando sincronización de metadata.", "error"); } } function startGlobalSyncPolling() { isSyncingGlobally = true; document.getElementById("btn-sync-global").disabled = true; const syncMenuBtn = document.getElementById("btn-sync-menu"); const syncMetadataBtn = document.getElementById("btn-sync-metadata"); if (syncMenuBtn) syncMenuBtn.disabled = true; if (syncMetadataBtn) syncMetadataBtn.disabled = true; document.getElementById("sync-status-bar").classList.remove("hidden"); syncProgressInterval = setInterval(async () => { try { const response = await fetch("/api/sync/progress"); const progress = await response.json(); const total = progress.total || 1; const done = progress.done || 0; const pct = ((done / total) * 100).toFixed(0); const workers = progress.max_workers ? ` | workers: ${progress.max_workers}` : ""; document.getElementById("sync-progress-fill").style.width = `${pct}%`; document.getElementById("sync-progress-text").innerText = `Sync: ${done}/${total} (${progress.current_branch})${workers}`; if (!progress.is_running) { clearInterval(syncProgressInterval); isSyncingGlobally = false; document.getElementById("btn-sync-global").disabled = false; if (syncMenuBtn) syncMenuBtn.disabled = false; if (syncMetadataBtn) syncMetadataBtn.disabled = false; document.getElementById("sync-status-bar").classList.add("hidden"); showToast("Sincronización global completada con éxito.", "success"); // Recargar todo await loadBranches(); selectBranch(activeBranchId); } } catch (e) { console.error("Error polling progress", e); } }, 3000); } async function checkGlobalSyncProgress() { try { const response = await fetch("/api/sync/progress"); const progress = await response.json(); if (progress.is_running) { startGlobalSyncPolling(); } } catch (e) { console.error("Error checking sync progress", e); } } // --- UTILERÍAS --- function formatCurrency(value) { const val = parseFloat(value) || 0.0; return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(val); } // --- ACCIONES GLOBALES Y TABLA MASTER --- function renderGlobalMasterTable(filterQuery = "") { const tbody = document.getElementById("global-master-rows"); if (!tbody) return; tbody.innerHTML = ""; const q = filterQuery.toLowerCase().trim(); // Filtrar cuentas (sucursales y marca principal) const filtered = branchesData.filter(b => { const nameMatch = b.nombre.toLowerCase().includes(q); const idMatch = b.location_id.toLowerCase().includes(q); return nameMatch || idMatch; }); if (filtered.length === 0) { tbody.innerHTML = `No se encontraron sucursales que coincidan con la búsqueda.`; return; } filtered.forEach((b, index) => { const m = b.metrics || { contacts_count: 0, contacts_without_opp_count: 0, opps_count: 0, opps_by_status: { won: { value: 0 }, open: { value: 0 } } }; let displayNum = "-"; let displayName = b.nombre; const match = b.nombre.match(/^(\d+)\s*-\s*MP\s*-\s*(.*)$/); if (match) { displayNum = match[1]; displayName = match[2]; } else if (b.type === "brand") { displayNum = "★"; displayName = b.nombre; } const wonVal = m.opps_by_status?.won?.value || 0; const openVal = m.opps_by_status?.open?.value || 0; const noOppCount = m.contacts_without_opp_count || 0; const noOppPct = m.contacts_count > 0 ? Math.round((noOppCount / m.contacts_count) * 100) : 0; let noOppCellHTML; if (noOppCount === 0) { noOppCellHTML = ` 0`; } else { const sev = noOppPct >= 20 ? "danger" : "warning"; noOppCellHTML = ``; } // Formatear estado de sync let syncStatusHTML = 'N/A'; if (m.last_sync) { const status = m.last_sync.status; const time = m.last_sync.finished_at ? m.last_sync.finished_at.substring(11, 16) : (m.last_sync.started_at ? m.last_sync.started_at.substring(11, 16) : ""); const date = m.last_sync.finished_at ? m.last_sync.finished_at.substring(5, 10) : (m.last_sync.started_at ? m.last_sync.started_at.substring(5, 10) : ""); if (status === "success") { syncStatusHTML = ` Éxito (${date} ${time})`; } else if (status === "failed") { syncStatusHTML = ` Falló`; } else if (status === "running") { syncStatusHTML = ` Sync...`; } } const tr = document.createElement("tr"); if (b.type === "brand") { tr.style.background = "rgba(124, 58, 237, 0.04)"; tr.style.borderLeft = "3px solid var(--color-primary)"; } tr.innerHTML = ` ${displayNum}
    ${displayName}
    ${b.location_id.substring(0, 12)}...
    ${m.contacts_count} ${noOppCellHTML} ${m.opps_count} ${m.pipelines_count || 0} ${formatCurrency(wonVal)} ${formatCurrency(openVal)} ${syncStatusHTML}
    `; // Event listeners para botones tr.querySelector(".btn-view-branch").addEventListener("click", () => { selectBranch(b.location_id); }); const noOppBtn = tr.querySelector(".btn-view-no-opp"); if (noOppBtn) { noOppBtn.addEventListener("click", (e) => { e.stopPropagation(); openBranchWithoutOppView(b.location_id); }); } tr.querySelector(".btn-sync-branch-inline").addEventListener("click", (e) => { triggerBranchSyncInline(b.location_id, e.currentTarget); }); tbody.appendChild(tr); }); } /** * Abre la sucursal indicada, salta a la pestaña Contactos y activa el filtro * "solo sin oportunidad". Es el destino de los badges naranjas del Master. */ function openBranchWithoutOppView(locationId) { // 1) Cambiar sucursal (esto resetea el filtro a false internamente). selectBranch(locationId); // 2) Re-activar el filtro después del reset implícito. contactsWithoutOppFilter = true; currentContactsPage = 1; // 3) Saltar a la pestaña Contactos (su click dispara loadContactsTable // que ya leerá el filtro activo). const contactsTab = document.querySelector('.tab-link[data-tab="tab-contacts"]'); if (contactsTab) { contactsTab.click(); } else { loadContactsTable(); } } async function triggerBranchSyncInline(locationId, btn) { if (btn.disabled) return; const originalHTML = btn.innerHTML; btn.innerHTML = ``; btn.disabled = true; const b = branchesData.find(x => x.location_id === locationId); const branchName = b ? b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, "") : locationId; showToast(`Iniciando sincronización de ${branchName}...`, "info"); try { const response = await fetch(`/api/sync/${locationId}`, { method: "POST" }); const data = await response.json(); showToast(data.message, "success"); // Encender polling corto para esta sucursal (12 segundos) setTimeout(async () => { await loadBranches(); // Si el branch sigue seleccionado en el master, refresca la tabla master if (activeBranchId === "global") { renderGlobalMasterTable(document.getElementById("global-master-search")?.value || ""); // También actualizar sidebar renderBranchesSidebar(); } else if (activeBranchId === locationId) { selectBranch(activeBranchId); } showToast(`Sincronización de ${branchName} finalizada con éxito.`, "success"); btn.innerHTML = originalHTML; btn.disabled = false; }, 12000); } catch (e) { showToast(`No se pudo iniciar la sincronización de ${branchName}.`, "error"); btn.innerHTML = originalHTML; btn.disabled = false; } } // ========================================================================== // WORKFLOWS CONTROLLER // ========================================================================== let workflowsData = []; // Mapa: workflowKey -> {workflow_id, location_id, current_status, name, account_name} // workflowKey = `${location_id}:${workflow_id}` (un workflow_id puede repetirse entre sucursales) const selectedWorkflows = new Map(); function workflowKey(locationId, workflowId) { return `${locationId}:${workflowId}`; } let workflowSort = { field: null, direction: "asc" }; const WORKFLOW_DATE_FIELDS = new Set(["created_at", "updated_at", "synced_at"]); function onWorkflowHeaderSortClick(event) { const th = event.currentTarget; const field = th.dataset.sortField; if (!field) return; if (workflowSort.field === field) { workflowSort.direction = workflowSort.direction === "asc" ? "desc" : "asc"; } else { workflowSort.field = field; workflowSort.direction = "asc"; } renderWorkflowsTable(); } function updateWorkflowSortIndicators() { document.querySelectorAll("#tab-workflows .workflow-sortable").forEach(th => { th.classList.remove("sort-asc", "sort-desc"); if (th.dataset.sortField === workflowSort.field) { th.classList.add(workflowSort.direction === "asc" ? "sort-asc" : "sort-desc"); } }); } async function loadWorkflowsTable() { const tableRows = document.getElementById("workflows-table-rows"); if (!tableRows) return; document.querySelectorAll("#tab-workflows .workflow-sortable").forEach(th => { if (th.dataset.sortBound) return; th.addEventListener("click", onWorkflowHeaderSortClick); th.dataset.sortBound = "1"; }); tableRows.innerHTML = ` Cargando workflows... `; try { let url = "/api/workflows"; if (activeBranchId !== "global") { url += `?location_id=${activeBranchId}`; } const response = await fetch(url); if (!response.ok) throw new Error("Error fetching workflows"); workflowsData = await response.json(); populateWorkflowsBranchSelect(); renderWorkflowsTable(); updateBucefaloSessionIndicator(); } catch (error) { console.error("Error loading workflows:", error); tableRows.innerHTML = ` Error al cargar workflows: ${error.message} `; } } function populateWorkflowsBranchSelect() { const select = document.getElementById("workflow-branch-select"); if (!select) return; select.innerHTML = ''; if (activeBranchId === "global") { select.disabled = false; const uniqueBranches = {}; workflowsData.forEach(w => { if (w.location_id && w.account_name) { uniqueBranches[w.location_id] = w.account_name; } }); Object.keys(uniqueBranches).sort((a,b) => uniqueBranches[a].localeCompare(uniqueBranches[b])).forEach(locId => { const opt = document.createElement("option"); opt.value = locId; opt.innerText = uniqueBranches[locId]; select.appendChild(opt); }); } else { select.disabled = true; const b = branchesData.find(x => x.location_id === activeBranchId); if (b) { select.innerHTML = ``; } } } function renderWorkflowsTable() { const tableRows = document.getElementById("workflows-table-rows"); if (!tableRows) return; const branchFilter = document.getElementById("workflow-branch-select")?.value || "all"; const statusFilter = document.getElementById("workflow-status-select")?.value || "all"; const searchQuery = (document.getElementById("workflow-table-search")?.value || "").toLowerCase().trim(); const dateField = document.getElementById("workflow-date-field")?.value || "created_at"; const dateAfterRaw = document.getElementById("workflow-date-after")?.value || ""; const dateBeforeRaw = document.getElementById("workflow-date-before")?.value || ""; const dateAfterTs = dateAfterRaw ? new Date(dateAfterRaw).getTime() : null; const dateBeforeTs = dateBeforeRaw ? new Date(dateBeforeRaw).getTime() : null; const filtered = workflowsData.filter(w => { if (activeBranchId === "global") { if (branchFilter !== "all" && w.location_id !== branchFilter) return false; } if (statusFilter !== "all") { if (statusFilter === "active" && w.status !== "active" && w.status !== "published") return false; if (statusFilter === "inactive" && w.status !== "inactive") return false; if (statusFilter === "draft" && w.status !== "draft") return false; } if (searchQuery) { const nameMatch = w.name?.toLowerCase().includes(searchQuery); const idMatch = w.id?.toLowerCase().includes(searchQuery); const triggerMatch = w.trigger?.toLowerCase().includes(searchQuery); if (!nameMatch && !idMatch && !triggerMatch) return false; } if (dateAfterTs !== null || dateBeforeTs !== null) { const raw = w[dateField]; if (!raw) return false; const ts = new Date(raw).getTime(); if (Number.isNaN(ts)) return false; if (dateAfterTs !== null && ts < dateAfterTs) return false; if (dateBeforeTs !== null && ts > dateBeforeTs) return false; } return true; }); if (workflowSort.field) { const field = workflowSort.field; const dir = workflowSort.direction === "desc" ? -1 : 1; const isDate = WORKFLOW_DATE_FIELDS.has(field); filtered.sort((a, b) => { const av = a[field]; const bv = b[field]; const aEmpty = av === null || av === undefined || av === ""; const bEmpty = bv === null || bv === undefined || bv === ""; if (aEmpty && bEmpty) return 0; if (aEmpty) return 1; // vacíos siempre al final if (bEmpty) return -1; if (isDate) { const at = new Date(av).getTime(); const bt = new Date(bv).getTime(); if (Number.isNaN(at) && Number.isNaN(bt)) return 0; if (Number.isNaN(at)) return 1; if (Number.isNaN(bt)) return -1; return (at - bt) * dir; } return String(av).localeCompare(String(bv), "es", { numeric: true, sensitivity: "base" }) * dir; }); } updateWorkflowSortIndicators(); if (filtered.length === 0) { tableRows.innerHTML = ` No se encontraron workflows con los filtros seleccionados. `; return; } tableRows.innerHTML = filtered.map(w => { let statusBadgeClass = "badge-status-draft"; let statusLabel = "Borrador"; if (w.status === "active" || w.status === "published") { statusBadgeClass = "badge-status-active"; statusLabel = "Activo"; } else if (w.status === "inactive") { statusBadgeClass = "badge-status-inactive"; statusLabel = "Pausado"; } const triggerLabel = w.trigger || '-'; const dateFormatted = w.created_at ? new Date(w.created_at).toLocaleString('es-ES') : '-'; const updatedFormatted = w.updated_at ? new Date(w.updated_at).toLocaleString('es-ES') : '-'; const syncedFormatted = w.synced_at ? new Date(w.synced_at).toLocaleString('es-ES') : '-'; const isPublished = w.status === "active" || w.status === "published"; const toggleIcon = isPublished ? "fa-toggle-on text-success" : "fa-toggle-off text-muted"; const toggleTitle = isPublished ? "Pausar Workflow (Draft)" : "Activar Workflow (Publish)"; const key = workflowKey(w.location_id, w.id); const isChecked = selectedWorkflows.has(key) ? "checked" : ""; return ` ${w.account_name || w.location_id} ${w.id} ${w.name}${(() => { const cache = window.__workflowAnomaliesCache || {}; const k = `${w.location_id}::${w.id}`; const n = cache[k]; if (n === undefined) return ""; if (n === 0) return ` ✓ limpio`; return ` ⚠ ${n} anomalía(s)`; })()} ${statusLabel} ${triggerLabel} ${dateFormatted} ${updatedFormatted} ${syncedFormatted}
    `; }).join(""); // Bindear los checkboxes recién renderizados al estado de selección. document.querySelectorAll(".workflow-row-check").forEach(cb => { cb.addEventListener("change", onWorkflowRowCheckChange); }); updateBulkBarUI(); } function onWorkflowRowCheckChange(e) { const cb = e.target; const key = cb.dataset.key; if (cb.checked) { selectedWorkflows.set(key, { workflow_id: cb.dataset.id, location_id: cb.dataset.location, current_status: cb.dataset.status, name: cb.dataset.name, account_name: cb.dataset.account, }); } else { selectedWorkflows.delete(key); } updateBulkBarUI(); } function updateBulkBarUI() { const bar = document.getElementById("workflows-bulk-bar"); const counter = document.getElementById("workflows-bulk-count"); if (!bar || !counter) return; const inProgress = bulkProgressState !== null; const n = selectedWorkflows.size; if (inProgress) { // En modo progreso, la barra queda visible aunque no haya selección. bar.classList.remove("hidden"); } else if (n > 0) { bar.classList.remove("hidden"); counter.textContent = `${n} ${n === 1 ? "seleccionado" : "seleccionados"}`; } else { bar.classList.add("hidden"); } // Sincronizar el checkbox maestro: marcado si TODAS las filas visibles están seleccionadas. const master = document.getElementById("workflows-select-all"); const rowChecks = document.querySelectorAll(".workflow-row-check"); if (master && rowChecks.length > 0) { const total = rowChecks.length; const checked = Array.from(rowChecks).filter(c => c.checked).length; master.checked = checked === total; master.indeterminate = checked > 0 && checked < total; } else if (master) { master.checked = false; master.indeterminate = false; } } function clearWorkflowSelection() { selectedWorkflows.clear(); document.querySelectorAll(".workflow-row-check").forEach(cb => { cb.checked = false; }); updateBulkBarUI(); } function toggleWorkflowsSelectAll(e) { const checked = e.target.checked; document.querySelectorAll(".workflow-row-check").forEach(cb => { if (cb.checked !== checked) { cb.checked = checked; // Disparar el handler para que actualice el Map. cb.dispatchEvent(new Event("change")); } }); } // ---------- Estado del progreso del bulk ---------- let bulkProgressState = null; let bulkProgressEventSource = null; function bulkEnterProgressMode(targetLabel) { // Oculta la fila de botones y muestra la sección de progreso. document.getElementById("workflows-bulk-actions").classList.add("hidden"); const progress = document.getElementById("workflows-bulk-progress"); progress.classList.remove("hidden"); progress.style.display = "flex"; document.getElementById("workflows-bulk-bar").classList.remove("hidden"); document.getElementById("bulk-progress-title").innerHTML = ` Procesando: ${targetLabel}`; document.getElementById("bulk-progress-counter").textContent = "0/0"; document.getElementById("bulk-progress-fill").style.width = "0%"; document.getElementById("bulk-progress-current").textContent = "Iniciando…"; document.getElementById("bulk-progress-summary").classList.add("hidden"); // Mostrar botón "Cancelar" mientras el escaneo está activo. const cancelBtn = document.getElementById("btn-bulk-cancel"); if (cancelBtn && bulkProgressState && bulkProgressState.taskId) { cancelBtn.classList.remove("hidden"); } else if (cancelBtn) { cancelBtn.classList.add("hidden"); } } function bulkExitProgressMode() { document.getElementById("workflows-bulk-actions").classList.remove("hidden"); const progress = document.getElementById("workflows-bulk-progress"); progress.classList.add("hidden"); progress.style.display = "none"; document.getElementById("bulk-progress-summary").classList.add("hidden"); const retryBtn = document.getElementById("btn-bulk-retry-pending"); if (retryBtn) retryBtn.classList.add("hidden"); if (bulkProgressEventSource) { bulkProgressEventSource.close(); bulkProgressEventSource = null; } bulkProgressState = null; } function bulkRenderProgress() { if (!bulkProgressState) return; const st = bulkProgressState; const total = st.total || 0; const done = st.done || 0; const pct = total > 0 ? Math.round((done / total) * 100) : 0; document.getElementById("bulk-progress-counter").textContent = `${done}/${total}`; document.getElementById("bulk-progress-fill").style.width = `${pct}%`; const isScanAnomalies = st.target === "scan-anomalies"; const totalAnoms = (st.findings || []).length; const workflowsWithAnoms = Object.keys(st.anomaliesPerWorkflow || {}).filter(k => st.anomaliesPerWorkflow[k] > 0).length; if (st.finished) { if (st.sessionExpired) { document.getElementById("bulk-progress-title").innerHTML = ` Lote interrumpido — sesión Bucéfalo expirada`; document.getElementById("bulk-progress-current").innerHTML = `Acción requerida: renueva la sesión arriba y dale a "Reintentar pendientes".`; } else if (isScanAnomalies && totalAnoms > 0) { document.getElementById("bulk-progress-title").innerHTML = ` ${totalAnoms} anomalía(s) detectada(s) en ${workflowsWithAnoms} workflow(s)`; document.getElementById("bulk-progress-current").innerHTML = `Revisa el detalle abajo y abre cada workflow para corregir.`; } else if (isScanAnomalies) { document.getElementById("bulk-progress-title").innerHTML = ` Escaneo completo — sin anomalías detectadas`; document.getElementById("bulk-progress-current").textContent = "Todos los workflows escaneados están limpios."; } else { document.getElementById("bulk-progress-title").innerHTML = ` Procesamiento completado`; document.getElementById("bulk-progress-current").textContent = "Tabla actualizándose…"; } document.getElementById("bulk-progress-summary").classList.remove("hidden"); document.getElementById("bulk-progress-summary").style.display = "flex"; if (isScanAnomalies) { document.getElementById("bulk-progress-success-count").innerHTML = ` ${st.success || 0} escaneados`; } else { document.getElementById("bulk-progress-success-count").innerHTML = ` ${st.success || 0} OK`; } document.getElementById("bulk-progress-skipped-count").innerHTML = ` ${st.skipped || 0} omitidos`; document.getElementById("bulk-progress-failed-count").innerHTML = ` ${st.failed || 0} fallos`; const anomCountEl = document.getElementById("bulk-progress-anomalies-count"); if (isScanAnomalies && totalAnoms > 0) { anomCountEl.innerHTML = ` ${totalAnoms} anomalías`; anomCountEl.classList.remove("hidden"); } else if (anomCountEl) { anomCountEl.classList.add("hidden"); } const dlBtn = document.getElementById("btn-bulk-download-report"); if (dlBtn) { if (isScanAnomalies && st.reportCsv) { dlBtn.classList.remove("hidden"); dlBtn.onclick = () => { window.open(`/api/exports/${encodeURIComponent(st.reportCsv)}`, "_blank"); }; } else { dlBtn.classList.add("hidden"); } } const xlsxBtn = document.getElementById("btn-bulk-download-xlsx"); if (xlsxBtn) { if (isScanAnomalies && st.reportCsv && totalAnoms > 0) { xlsxBtn.classList.remove("hidden"); xlsxBtn.onclick = () => { window.open(`/api/workflows/anomaly-report-xlsx/${encodeURIComponent(st.reportCsv)}`, "_blank"); }; } else { xlsxBtn.classList.add("hidden"); } } // Ocultar botón Cancelar al finalizar. const cancelBtn = document.getElementById("btn-bulk-cancel"); if (cancelBtn) cancelBtn.classList.add("hidden"); // Botón "Reintentar pendientes" solo si hubo sesión expirada y hay items no-exitosos. const retryBtn = document.getElementById("btn-bulk-retry-pending"); const retryCountEl = document.getElementById("bulk-retry-count"); if (retryBtn && retryCountEl) { const pending = _bulkGetPendingItems(); if (st.sessionExpired && pending.length > 0) { retryCountEl.textContent = pending.length; retryBtn.classList.remove("hidden"); } else { retryBtn.classList.add("hidden"); } } // Render del panel de anomalías cuando aplique. bulkRenderAnomaliesPanel(); // Toast informativo al finalizar el escaneo. if (isScanAnomalies && !st._toastShown) { st._toastShown = true; if (totalAnoms > 0) { showToast(`${totalAnoms} anomalía(s) detectada(s) en ${workflowsWithAnoms} workflow(s). Revisa el panel.`, "warning"); } else { showToast("Escaneo completo — ningún workflow tiene anomalías.", "success"); } } } else if (st.currentName) { const acc = st.currentAccount ? ` en ${st.currentAccount}` : ""; const runningAnoms = totalAnoms > 0 ? ` (${totalAnoms} anomalías detectadas hasta ahora)` : ""; document.getElementById("bulk-progress-current").innerHTML = `Trabajando en: ${st.currentName}${acc}${runningAnoms}`; } } function bulkRenderAnomaliesPanel() { const panel = document.getElementById("bulk-anomalies-panel"); if (!panel || !bulkProgressState) return; const st = bulkProgressState; const isScanAnomalies = st.target === "scan-anomalies"; const findings = st.findings || []; if (!isScanAnomalies || findings.length === 0) { panel.classList.add("hidden"); return; } panel.classList.remove("hidden"); const workflowsWithAnoms = Object.keys(st.anomaliesPerWorkflow || {}).filter(k => st.anomaliesPerWorkflow[k] > 0).length; document.getElementById("bulk-anomalies-title").textContent = `${findings.length} anomalía(s) detectada(s) en ${workflowsWithAnoms} workflow(s)`; const list = document.getElementById("bulk-anomalies-list"); // Agrupar por workflow. const byWf = {}; for (const f of findings) { const key = `${f.locationId}::${f.workflowId}`; if (!byWf[key]) byWf[key] = { locationId: f.locationId, workflowId: f.workflowId, workflowName: f.workflowName, accountName: f.accountName, items: [] }; byWf[key].items.push(f); } const groupsHtml = Object.values(byWf).map(g => { // Agrupar findings idénticos por (node, type, desc). Si 3 nodos "Crear oportunidad" // tienen el mismo error, los mostramos como UNA entrada con "3×" y los StepIds en lista. const dedup = new Map(); for (const f of g.items) { const key = `${f.type}|${f.node}|${f.desc}`; if (!dedup.has(key)) dedup.set(key, {type: f.type, node: f.node, desc: f.desc, ids: []}); if (f.id) dedup.get(key).ids.push(f.id); } const itemsHtml = Array.from(dedup.values()).map(f => { const typeLabel = ({ "ghl_native_error": "Error nativo de GHL", "alert_icon": "Ícono de alerta", "unresolved_field_id": "ID de campo sin resolver", "n_button_alert": "Aviso en cabecera", })[f.type] || f.type; const typeColor = f.type === "ghl_native_error" ? "#F87171" : "#F59E0B"; const count = f.ids.length || 1; const countPrefix = count > 1 ? `${count}×` : ""; const idsHtml = f.ids.length > 0 ? `
    ${f.ids.join("\n")}
    ` : ""; return `
    ${typeLabel} ${countPrefix}${f.node || "(sin nombre)"}
    ${f.desc ? `
    ${f.desc}
    ` : ""} ${idsHtml}
    `; }).join(""); const wfUrl = `https://crm.bucefalocrm.io/location/${g.locationId}/workflow/${g.workflowId}`; return `
    ${g.workflowName} ${g.accountName || g.locationId} ${g.items.length} anomalía(s) Abrir en Bucéfalo
    ${itemsHtml}
    `; }).join(""); list.innerHTML = groupsHtml; } async function cancelBulkScan() { if (!bulkProgressState || !bulkProgressState.taskId) { showToast("No hay escaneo activo para cancelar.", "warning"); return; } const ok = await appConfirm({ title: "Cancelar escaneo", message: "El subproceso de Playwright se detendrá ahora. Los workflows ya escaneados aparecerán en el reporte parcial; el resto quedarán como pendientes.", confirmText: "Sí, cancelar", cancelText: "Continuar escaneo", severity: "danger", }); if (!ok) return; try { const res = await fetch(`/api/scripts/stop/${bulkProgressState.taskId}`, {method: "POST"}); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "No se pudo cancelar"); bulkProgressState.cancelled = true; showToast("Escaneo cancelado. Cerrando subproceso...", "info"); } catch (err) { showToast(`Error: ${err.message}`, "error"); } } function bulkToggleAnomaliesList() { const list = document.getElementById("bulk-anomalies-list"); const label = document.getElementById("bulk-anomalies-toggle-label"); const icon = document.querySelector("#btn-bulk-anomalies-toggle i"); if (!list) return; const hidden = list.classList.toggle("hidden"); if (label) label.textContent = hidden ? "Ver detalle" : "Ocultar detalle"; if (icon) icon.className = hidden ? "fa-solid fa-chevron-down" : "fa-solid fa-chevron-up"; } function bulkParseLine(line) { if (!bulkProgressState) return; // "[BULK-DRAFT] Iniciando procesamiento de N workflows..." let m = line.match(/Iniciando procesamiento de (\d+) workflows/); if (m) { bulkProgressState.total = parseInt(m[1], 10); bulkRenderProgress(); return; } // "[BULK X/N] === 'name' (id) en location_id ===" m = line.match(/\[BULK (\d+)\/(\d+)\] === '([^']+)' \(([^)]+)\) en (\S+) ===/); if (m) { bulkProgressState.currentIdx = parseInt(m[1], 10); bulkProgressState.total = parseInt(m[2], 10); bulkProgressState.currentName = m[3]; // Resolver el nombre de la sucursal desde workflowsData si está disponible. const locId = m[5]; const accMatch = workflowsData.find(w => w.location_id === locId); bulkProgressState.currentAccount = accMatch ? accMatch.account_name : locId; bulkRenderProgress(); return; } // NOTA: las líneas "[BULK X/N] SKIP — …" se ignoran a propósito. La fuente de verdad // para contar items es exclusivamente "RESULT:" emitido al final de cada item. Cada SKIP // del script va siempre acompañado de un RESULT: skipped a continuación. // Marcador final por item: "[BULK X/N] RESULT: success|failed|skipped" m = line.match(/\[BULK (\d+)\/(\d+)\] RESULT: (success|failed|skipped)/); if (m) { const idx0 = parseInt(m[1], 10) - 1; const status = m[3]; if (status === "success") { bulkProgressState.success = (bulkProgressState.success || 0) + 1; } else if (status === "skipped") { bulkProgressState.skipped = (bulkProgressState.skipped || 0) + 1; } else { bulkProgressState.failed = (bulkProgressState.failed || 0) + 1; } bulkProgressState.done = (bulkProgressState.done || 0) + 1; // Track del status por índice para identificar pendientes al final. if (bulkProgressState.itemStatus && idx0 >= 0 && idx0 < bulkProgressState.itemStatus.length) { bulkProgressState.itemStatus[idx0] = status; } bulkRenderProgress(); return; } // Aborto por sesión expirada: marcar el estado para mostrar banner al final. if (/\[BULK \d+\/\d+\] SESIÓN EXPIRADA/.test(line) || /\[ABORTO\] .* abortado por sesión expirada/.test(line)) { bulkProgressState.sessionExpired = true; return; } // Hallazgo individual de anomalía: "[BULK X/N] FINDING: type=...|node=...|id=...|desc=..." m = line.match(/\[BULK (\d+)\/(\d+)\] FINDING:\s*(.+)$/); if (m) { const idx0 = parseInt(m[1], 10) - 1; const item = (bulkProgressState.items || [])[idx0] || {}; const parts = {}; for (const kv of m[3].split("|")) { const eq = kv.indexOf("="); if (eq > 0) parts[kv.slice(0, eq).trim()] = kv.slice(eq + 1).trim(); } bulkProgressState.findings = bulkProgressState.findings || []; bulkProgressState.findings.push({ workflowId: item.workflow_id || "", locationId: item.location_id || "", workflowName: item.name || "", accountName: item.account_name || "", type: parts.type || "", node: parts.node || "", id: parts.id || "", desc: parts.desc || "", }); // Re-render durante el progreso (con throttle implícito porque solo se llama por línea). bulkRenderAnomaliesPanel(); bulkRenderProgress(); return; } // Conteo de anomalías por workflow: "[BULK X/N] ANOMALIES: N" m = line.match(/\[BULK (\d+)\/(\d+)\] ANOMALIES:\s*(\d+)/); if (m) { const idx0 = parseInt(m[1], 10) - 1; const count = parseInt(m[3], 10); bulkProgressState.anomaliesPerWorkflow = bulkProgressState.anomaliesPerWorkflow || {}; const item = (bulkProgressState.items || [])[idx0]; if (item) { const key = `${item.location_id}::${item.workflow_id}`; bulkProgressState.anomaliesPerWorkflow[key] = count; // Marcar el workflow en el cache global para que renderWorkflowsTable pinte un badge. window.__workflowAnomaliesCache = window.__workflowAnomaliesCache || {}; window.__workflowAnomaliesCache[key] = count; } bulkRenderProgress(); return; } // Path del CSV del reporte: "[INFO] REPORT_CSV: " m = line.match(/\[INFO\]\s+REPORT_CSV:\s*(.+)$/); if (m) { bulkProgressState.reportCsv = m[1].trim(); bulkRenderProgress(); return; } m = line.match(/\[INFO\]\s+REPORT_JSON:\s*(.+)$/); if (m) { bulkProgressState.reportJson = m[1].trim(); return; } // Resumen final. if (/=== RESUMEN BULK-/.test(line)) { bulkProgressState.finished = true; bulkRenderProgress(); // Refrescar la tabla 3s después de ver el resumen para dar margen al sync local. // EXCEPCIÓN: scan-anomalies es read-only, recargar la tabla borra los badges recién pintados. if (bulkProgressState.target !== "scan-anomalies") { setTimeout(() => loadWorkflowsTable(), 3000); } else { // Refrescar solo el render para que los badges aparezcan en las filas. setTimeout(() => { if (typeof renderWorkflowsTable === "function") renderWorkflowsTable(); }, 500); } return; } } function bulkStartSseStream(taskId) { if (bulkProgressEventSource) { bulkProgressEventSource.close(); } bulkProgressEventSource = new EventSource(`/api/scripts/stream/${taskId}`); bulkProgressEventSource.onmessage = (ev) => { const line = ev.data || ""; if (line === "[EOF]") { // El stream terminó. Si el resumen no marcó finished, marcarlo ahora. if (bulkProgressState && !bulkProgressState.finished) { bulkProgressState.finished = true; bulkRenderProgress(); setTimeout(() => loadWorkflowsTable(), 2000); } bulkProgressEventSource.close(); bulkProgressEventSource = null; return; } try { bulkParseLine(line); } catch (e) { console.warn("bulkParseLine:", e); } }; bulkProgressEventSource.onerror = () => { // SSE puede reconectar solo; lo cerramos si la tarea terminó. if (bulkProgressState && bulkProgressState.finished) { bulkProgressEventSource.close(); bulkProgressEventSource = null; } }; } async function triggerBulkToggle(target) { // target: "draft" | "publish" | "delete" if (selectedWorkflows.size === 0) { showToast("No hay workflows seleccionados.", "warning"); return; } const items = Array.from(selectedWorkflows.values()); let targetLabel, msg; if (target === "delete") { targetLabel = "Eliminar"; const names = items.slice(0, 5).map(i => `• ${i.name} (${i.account_name || i.location_id})`).join("\n"); const more = items.length > 5 ? `\n…y ${items.length - 5} más.` : ""; const summaryMsg = `Vas a eliminar ${items.length} workflow(s) de Bucéfalo:\n\n${names}${more}\n\nEsta acción NO se puede deshacer desde aquí (GHL los mueve a "Eliminado" 30 días y luego los borra permanentemente).`; const okSummary = await appConfirm({ title: "Eliminación permanente", message: summaryMsg, confirmText: "Continuar", severity: "danger", }); if (!okSummary) return; const okTyped = await appPrompt({ title: "Confirma la eliminación", message: `Para confirmar definitivamente, escribe ELIMINAR (en mayúsculas):`, expectedAnswer: "ELIMINAR", placeholder: "ELIMINAR", severity: "danger", }); if (!okTyped) { showToast("Confirmación cancelada — no se eliminó nada.", "info"); return; } msg = null; // Ya confirmamos. } else { const isPublish = target === "publish"; targetLabel = isPublish ? "Publicado" : "Borrador"; const alreadyAtTarget = items.filter(i => { const isActive = i.current_status === "active" || i.current_status === "published"; return isActive === isPublish; }).length; const willMutate = items.length - alreadyAtTarget; msg = `Vas a poner ${willMutate} workflow(s) en ${targetLabel}.`; if (alreadyAtTarget > 0) msg += ` (${alreadyAtTarget} ya están en ${targetLabel} y se omitirán.)`; msg += "\n\nLa operación se ejecuta secuencialmente uno por uno."; const ok = await appConfirm({ title: `Poner workflows en ${targetLabel}`, message: msg, confirmText: "Continuar", severity: "info", }); if (!ok) return; } // Delegar al ejecutor compartido (también lo usa el botón "Reintentar pendientes"). await _executeBulkOperation(target, items, targetLabel); } async function triggerBulkScanAnomalies() { if (selectedWorkflows.size === 0) { showToast("No hay workflows seleccionados.", "warning"); return; } const items = Array.from(selectedWorkflows.values()); const targetLabel = "Escanear anomalías"; const eta = Math.ceil((items.length * 40) / 60); const ok = await appConfirm({ title: targetLabel, message: `Vas a escanear ${items.length} workflow(s) en busca de nodos con anomalías (íconos de alerta, IDs de campos sin resolver, avisos globales). Es solo lectura — no modifica nada en Bucéfalo.\n\nTiempo estimado: ~${eta} min (≈40s por workflow).\n\nEl reporte queda en generated/reports/workflow_anomalies/.`, confirmText: "Iniciar escaneo", severity: "info", }); if (!ok) return; await _executeBulkOperation("scan-anomalies", items, targetLabel); } async function scanWorkflowAnomalies(workflowId, locationId, name, accountName) { const item = { workflow_id: workflowId, location_id: locationId, name: name || "", account_name: accountName || locationId, current_status: "", }; const ok = await appConfirm({ title: "Escanear anomalías", message: `Vas a abrir "${name}" en el editor visual y escanear sus nodos buscando anomalías (íconos de alerta, IDs de campos sin resolver, avisos globales). Solo lectura — no modifica nada en Bucéfalo.\n\nTarda ~30-40s.`, confirmText: "Iniciar escaneo", severity: "info", }); if (!ok) return; await _executeBulkOperation("scan-anomalies", [item], "Escanear anomalías"); } /** * Ejecuta el POST al backend de bulk-* y arma el progreso visual + tracking de items. * Se usa tanto desde `triggerBulkToggle` (flujo normal) como desde el botón de * "Reintentar pendientes" (que ya no pasa por confirmación). */ async function _executeBulkOperation(target, items, targetLabel) { try { const res = await mutateFetch(`/api/workflows/bulk-${target}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || `Error en bulk-${target}`); // Estado de progreso. `items` guardado completo para luego poder identificar pendientes. // `itemStatus` indexado por idx (1-based, matchea con [BULK X/N]). bulkProgressState = { total: items.length, currentIdx: 0, currentName: null, currentAccount: null, done: 0, success: 0, skipped: 0, failed: 0, finished: false, target, targetLabel, items: items.slice(), itemStatus: new Array(items.length).fill(null), // index 0-based; idx 1-based del log → idx-1 sessionExpired: false, }; bulkProgressState.taskId = data.task_id || null; bulkEnterProgressMode(targetLabel); clearWorkflowSelection(); document.getElementById("workflows-bulk-bar").classList.remove("hidden"); if (data.task_id) { bulkStartSseStream(data.task_id); } } catch (err) { showToast(`Error: ${err.message}`, "error"); } } function _bulkGetPendingItems() { // Items que NO terminaron con éxito tras un bulk interrumpido por sesión expirada. // Es decir: status != 'success'. Incluye 'failed', 'skipped' por sesión, y null (no procesados). if (!bulkProgressState || !bulkProgressState.items) return []; const pending = []; bulkProgressState.items.forEach((item, i) => { const st = bulkProgressState.itemStatus[i]; if (st !== "success") pending.push(item); }); return pending; } async function retryBulkPending() { if (!bulkProgressState) return; const pending = _bulkGetPendingItems(); if (pending.length === 0) { showToast("No hay pendientes para reintentar.", "info"); return; } const target = bulkProgressState.target; const targetLabel = bulkProgressState.targetLabel; // Confirmación corta (no doble paso aquí: el usuario ya confirmó la operación inicial). const ok = await appConfirm({ title: `Reintentar ${pending.length} pendientes`, message: `Vas a reintentar ${pending.length} workflow(s) que no se procesaron en el lote anterior (acción: ${targetLabel}).`, confirmText: "Reintentar", severity: target === "delete" ? "danger" : "info", }); if (!ok) return; // Cerrar el progreso viejo y arrancar uno nuevo con los pendientes. bulkExitProgressMode(); await _executeBulkOperation(target, pending, targetLabel); } async function triggerWorkflowsSync() { const btn = document.getElementById("btn-sync-workflows"); if (!btn) return; const oldHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Sincronizando...'; showToast("Iniciando sincronización de workflows...", "info"); try { let url = "/api/workflows/sync"; if (activeBranchId !== "global") { url += `?location_id=${activeBranchId}`; } const response = await fetch(url, { method: "POST" }); if (!response.ok) throw new Error("Error al disparar la sincronización"); const data = await response.json(); showToast(data.message, "success"); setTimeout(async () => { await loadWorkflowsTable(); showToast("Lista de workflows actualizada.", "success"); }, 3000); } catch (err) { console.error(err); showToast(`Error al sincronizar workflows: ${err.message}`, "error"); } finally { btn.disabled = false; btn.innerHTML = oldHtml; } } async function updateBucefaloSessionIndicator() { const el = document.getElementById("workflow-session-status"); if (!el) return; try { const res = await fetch("/api/browser-session/status"); if (!res.ok) return; const data = await res.json(); if (!data.exists) { el.innerHTML = ' Sesión Bucéfalo: no existe'; el.style.color = "#F87171"; return; } const ageTxt = data.age_hours == null ? "?" : `${data.age_hours} h`; if (data.stale) { el.innerHTML = ` Sesión Bucéfalo: ${ageTxt} (probablemente expirada)`; el.style.color = "#FBBF24"; } else { el.innerHTML = ` Sesión Bucéfalo: ${ageTxt}`; el.style.color = "#34D399"; } } catch (_) { // silencioso — el endpoint puede no existir en versiones viejas } } // Traduce líneas técnicas del log del session generator a estados humanos. function _interpretSessionLogLine(line) { if (/Iniciando flujo de auto-login/.test(line)) return { title: "Conectando a Bucéfalo…", detail: "Modo auto-login con credenciales del .env." }; if (/Llenando email \+ contraseña/.test(line)) return { title: "Llenando credenciales…", detail: "Completando email y contraseña." }; if (/Click en 'Iniciar sesión'/.test(line)) return { title: "Enviando credenciales…", detail: "" }; if (/Esperando selector de método 2FA/.test(line)) return { title: "Esperando selector de 2FA…", detail: "Bucéfalo va a pedir un código." }; if (/Método 2FA seleccionado: Email/.test(line)) return { title: "Solicitando código por correo…", detail: "El correo tarda 20–30 s en llegar." }; if (/Esperando código OTP por IMAP/.test(line)) return { title: "Esperando el código en el correo…", detail: "Leyendo INBOX por IMAP." }; const codeMatch = line.match(/Código obtenido:\s*(\d{6})/); if (codeMatch) return { title: "Código recibido", detail: `Pegando ${codeMatch[1]} en el formulario…` }; if (/Código tipeado en/.test(line)) return { title: "Verificando código…", detail: "" }; if (/Login completado/.test(line)) return { title: "Login completado", detail: "Guardando sesión…" }; if (/Lanzando navegador Chromium/.test(line)) return { title: "Abriendo navegador…", detail: "Completa login + MFA en la ventana." }; if (/Esperando hasta \d+ s a que completes login/.test(line)) return { title: "Esperando tu login…", detail: "Completa login + MFA en la ventana del navegador." }; if (/Login detectado/.test(line)) return { title: "Login detectado", detail: "Estabilizando cookies…" }; return null; } async function refreshBucefaloSession() { const ok = await appConfirm({ title: "Renovar sesión Bucéfalo", message: "Si configuraste .env con credenciales, el login es automático (sin ventana visible). Si no, se abre una ventana del navegador para que completes login + MFA.", confirmText: "Renovar", severity: "info", }); if (!ok) return; const btn = document.getElementById("btn-refresh-session"); const oldHtml = btn ? btn.innerHTML : null; if (btn) { btn.disabled = true; btn.innerHTML = ' Renovando…'; } const progress = appProgressModal({ title: "Renovando sesión Bucéfalo", initialDetail: "Iniciando…", severity: "info", }); progress.onClose(() => { if (btn) { btn.disabled = false; btn.innerHTML = oldHtml; } updateBucefaloSessionIndicator(); }); let lastTitle = "Iniciando…"; let sawSuccess = false; let lastErrorDetail = null; let sse = null; try { const res = await fetch("/api/browser-session/refresh", { method: "POST" }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || "Error al lanzar el generador de sesión"); const taskId = data.task_id; if (!taskId) { // Compatibilidad con endpoint viejo sin task_id. progress.setStatus("Generador iniciado", "Esperando que termine en background…"); setTimeout(() => progress.setSuccess("Listo", "Verifica el indicador de sesión."), 5000); return; } progress.setStatus("Conectando al subprocess…", "Esperando primeras líneas de log."); sse = new EventSource(`/api/scripts/stream/${taskId}`); sse.onmessage = async (ev) => { const line = ev.data || ""; // EOF marca el fin del stream del subprocess. if (line === "[EOF]") { if (sse) { sse.close(); sse = null; } if (sawSuccess) { progress.setSuccess("Sesión renovada", "Bucéfalo está listo para procesar acciones."); // Si hay un bulk previo en estado sessionExpired con pendientes, // resaltar visualmente el botón "Reintentar pendientes" para que el usuario lo note. setTimeout(() => { const retryBtn = document.getElementById("btn-bulk-retry-pending"); if (retryBtn && !retryBtn.classList.contains("hidden")) { retryBtn.style.transition = "transform 0.3s ease, box-shadow 0.3s ease"; retryBtn.style.transform = "scale(1.05)"; retryBtn.style.boxShadow = "0 0 0 4px rgba(124, 58, 237, 0.35)"; setTimeout(() => { retryBtn.style.transform = ""; retryBtn.style.boxShadow = ""; }, 1400); } }, 600); } else if (lastErrorDetail) { progress.setError("Falló la renovación", lastErrorDetail); } else { // No vimos éxito ni error claro: verificar el estado real de la sesión. try { const s = await fetch("/api/browser-session/status").then(r => r.json()); if (s.exists && (s.age_hours == null || s.age_hours < 0.5)) { progress.setSuccess("Sesión renovada", `Archivo actualizado (${s.age_hours ?? 0} h).`); } else { progress.setError("No se pudo confirmar la renovación", "Revisa la terminal del server para más detalle."); } } catch (_) { progress.setError("Sin confirmación", "El subprocess terminó pero no pude verificar el estado."); } } return; } // Interpretación humanizada del estado actual. const interp = _interpretSessionLogLine(line); if (interp) lastTitle = interp.title; const shortLine = line.length > 90 ? line.slice(0, 87) + "…" : line; progress.setStatus(lastTitle, interp ? interp.detail : undefined, shortLine); // Detección de éxito / error. if (/\[ÉXITO\] Sesión guardada en/.test(line) || /\[ÉXITO\] Perfil guardado en/.test(line)) { sawSuccess = true; } if (/Auto-login falló/.test(line) || /\[ERROR\]/.test(line) || /OtpReaderError/.test(line) || /AUTHENTICATIONFAILED/i.test(line) || /Tiempo agotado esperando el login/.test(line)) { lastErrorDetail = line.replace(/^\[(ERROR|AUTO|OTP)\]\s*/, '').slice(0, 200); } }; sse.onerror = () => { // Reconnects automáticos del EventSource; no actuamos salvo en EOF. }; } catch (err) { progress.setError("Error al iniciar", err.message || String(err)); } } async function exportWorkflowsExcel() { const btn = document.getElementById("btn-export-workflows"); if (!btn) return; const oldHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Exportando...'; try { const params = new URLSearchParams(); const branchFilter = document.getElementById("workflow-branch-select")?.value || "all"; const statusFilter = document.getElementById("workflow-status-select")?.value || "all"; const searchQuery = (document.getElementById("workflow-table-search")?.value || "").trim(); if (activeBranchId !== "global") { params.set("location_id", activeBranchId); } else if (branchFilter !== "all") { params.set("location_id", branchFilter); } if (statusFilter !== "all") { params.set("status", statusFilter); } if (searchQuery) { params.set("q", searchQuery); } const queryString = params.toString(); const response = await fetch(`/api/workflows/export${queryString ? `?${queryString}` : ""}`); if (!response.ok) throw new Error("Error al exportar workflows"); const blob = await response.blob(); const contentDisposition = response.headers.get("Content-Disposition") || ""; const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/); const filename = filenameMatch ? filenameMatch[1] : "workflows_ghl.xlsx"; const downloadUrl = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = downloadUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(downloadUrl); showToast("Excel de workflows descargado.", "success"); } catch (err) { console.error(err); showToast(`Error al exportar workflows: ${err.message}`, "error"); } finally { btn.disabled = false; btn.innerHTML = oldHtml; } } // --- FUNCIONES GESTIÓN DE WORKFLOWS CON PLAYWRIGHT --- async function toggleWorkflowState(workflowId, locationId, currentStatus) { const isActive = currentStatus === "active" || currentStatus === "published"; const targetLabel = isActive ? "Borrador" : "Publicado"; const ok = await appConfirm({ title: "Cambiar estado del workflow", message: `El workflow está actualmente en ${isActive ? "Publicado" : "Borrador"}.\n\nSe va a cambiar a ${targetLabel} en Bucéfalo.`, confirmText: "Cambiar", severity: "warning", }); if (!ok) return; showToast("Disparando cambio de estado...", "info"); mutateFetch(`/api/workflows/${workflowId}/toggle-status?location_id=${locationId}¤t_status=${currentStatus}`, { method: "POST" }) .then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); }) .then(data => { showToast("Proceso iniciado en segundo plano.", "success"); if (data.task_id) monitorPlaywrightTask(data.task_id); }) .catch(err => showToast(err.message, "error")); } function openRenameWorkflowModal(workflowId, locationId, currentName) { document.getElementById("rename-wf-id").value = workflowId; document.getElementById("rename-wf-location").value = locationId; document.getElementById("rename-wf-input").value = currentName; document.getElementById("modal-rename-workflow").classList.remove("hidden"); } function closeRenameWorkflowModal() { document.getElementById("modal-rename-workflow").classList.add("hidden"); } function submitRenameWorkflow() { const wfId = document.getElementById("rename-wf-id").value; const locId = document.getElementById("rename-wf-location").value; const newName = document.getElementById("rename-wf-input").value.trim(); if (!newName) { showToast("El nombre es requerido.", "error"); return; } closeRenameWorkflowModal(); showToast("Disparando renombrado en GHL...", "info"); mutateFetch(`/api/workflows/${wfId}/rename?location_id=${locId}&new_name=${encodeURIComponent(newName)}`, { method: "POST" }) .then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); }) .then(data => { showToast("Proceso iniciado.", "success"); if (data.task_id) monitorPlaywrightTask(data.task_id); }) .catch(err => showToast(err.message, "error")); } function openLocationInfoModal() { if (activeBranchId === "global") { showToast("Estás en la vista global consolidada. Elige una sucursal específica para ver sus pipelines.", "info"); return; } // Resetear/limpiar modal anterior document.getElementById("location-info-loader").classList.remove("hidden"); document.getElementById("location-info-content").classList.add("hidden"); document.getElementById("location-pipelines-container").innerHTML = ""; document.getElementById("modal-location-info").classList.remove("hidden"); // Obtener información de la ubicación/cuenta de GHL + local fetch(`/api/location/${activeBranchId}`) .then(res => { if (!res.ok) throw new Error("Error al consultar detalles de la cuenta"); return res.json(); }) .then(data => { const local = data.local_data || {}; const ghl = data.ghl_data || {}; document.getElementById("loc-info-name").innerText = ghl.name || local.nombre || "-"; document.getElementById("loc-info-id").innerText = local.location_id || activeBranchId; document.getElementById("loc-info-owner").innerText = ghl.business?.name || local.company_owner || "Monte Providencia"; document.getElementById("loc-info-phone").innerText = ghl.phone || "-"; document.getElementById("loc-info-email").innerText = ghl.email || "-"; document.getElementById("loc-info-timezone").innerText = ghl.timezone || "-"; const fullAddress = [ghl.address, ghl.city, ghl.state, ghl.country, ghl.postalCode] .filter(Boolean) .join(", ") || "No disponible"; document.getElementById("loc-info-address").innerText = fullAddress; // Ahora cargar pipelines de la misma cuenta/sucursal return fetch(`/api/pipelines/${activeBranchId}`); }) .then(res => { if (!res.ok) throw new Error("Error al consultar los pipelines de la cuenta"); return res.json(); }) .then(data => { const pipelines = data.pipelines || []; const pipelinesContainer = document.getElementById("location-pipelines-container"); pipelinesContainer.innerHTML = ""; if (pipelines.length === 0) { pipelinesContainer.innerHTML = `

    No se encontraron pipelines activos para esta cuenta en SQLite.

    `; } else { pipelines.forEach(pipe => { const pipeDiv = document.createElement("div"); pipeDiv.style.background = "rgba(255, 255, 255, 0.02)"; pipeDiv.style.border = "1px solid var(--border-glass)"; pipeDiv.style.borderRadius = "var(--radius-sm)"; pipeDiv.style.padding = "16px"; pipeDiv.style.marginBottom = "12px"; let stagesList = ""; if (pipe.stages && pipe.stages.length > 0) { stagesList = pipe.stages.map((stg, index) => `
    #${index + 1} ${stg.name}
    ${stg.id}
    `).join(""); } else { stagesList = `

    Sin fases / etapas registradas.

    `; } pipeDiv.innerHTML = `
    ${pipe.name}
    ${pipe.id}
    ${stagesList}
    `; pipelinesContainer.appendChild(pipeDiv); }); } // Quitar loader, mostrar contenido document.getElementById("location-info-loader").classList.add("hidden"); document.getElementById("location-info-content").classList.remove("hidden"); }) .catch(err => { console.error(err); showToast(err.message, "error"); closeLocationInfoModal(); }); } function closeLocationInfoModal() { document.getElementById("modal-location-info").classList.add("hidden"); } async function deleteWorkflow(workflowId, locationId, name) { const ok = await appConfirm({ title: "Eliminar workflow", message: `¿Eliminar permanentemente el workflow "${name}" en Bucéfalo?\n\nGHL lo mueve a "Eliminado" durante 30 días y luego lo borra para siempre.`, confirmText: "Eliminar", severity: "danger", }); if (!ok) return; showToast("Disparando eliminación...", "info"); mutateFetch(`/api/workflows/${workflowId}?location_id=${locationId}`, { method: "DELETE" }) .then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); }) .then(data => { showToast("Proceso de eliminación en curso.", "success"); if (data.task_id) monitorPlaywrightTask(data.task_id); }) .catch(err => showToast(err.message, "error")); } function monitorPlaywrightTask(taskId) { // Redirigir suavemente al usuario a la pestaña de terminal de scripts. const scriptTabBtn = document.querySelector('.tab-link[data-tab="tab-scripts"]'); if (scriptTabBtn) scriptTabBtn.click(); const term = ensureLiveTerminal(); if (!term) return; // Cerrar streams anteriores y limpiar buffer para el nuevo run. if (window.activeEventSource) { try { window.activeEventSource.close(); } catch (e) {} window.activeEventSource = null; } term.detach(); term.reset(); setTerminalState("running", "Ejecutando"); window.activeRunContext = { scriptName: "playwright", taskId: taskId }; term.appendSystem(`[SISTEMA]: Conectando con logs de Playwright (Task ID: ${taskId})...`, "info"); // Playwright también puede generar logs pesados (sesiones largas, OTPs, etc.). // Reusa el mismo bar sticky con contador. Playwright no tiene controles de // run (pause/resume/stop/rollback), así que mountRunControls lo deja vacío. setRunBarVisible(true, taskId); mountRunControls(taskId, false); const eventSource = term.attachStream(taskId); window.activeEventSource = eventSource; term.onEof = () => { term.appendSystem("[SISTEMA]: Ejecución de Playwright completada con éxito.", "info"); window.activeEventSource = null; setTerminalState("success", "Completado"); showToast("Acción finalizada y sincronizada en SQLite.", "success"); // Recargar tabla de workflows local. setTimeout(loadWorkflowsTable, 1000); }; term.onStreamError = () => { term.detach(); window.activeEventSource = null; setTerminalState("failed", "Falló"); }; } // ========================================================================== // COMPARATIVA MARCA vs SUCURSALES // ========================================================================== let comparativaState = { data: null, loading: false, bound: false, }; const BRAND_LOCATION_ID_UI = "GbKkBpCmKu2QmloKFHy3"; const BUCEFALO_BASE_URL = "https://crm.bucefalocrm.io"; function contactLink(name, contactId, locationId) { const safeName = escapeHtml(name || "(sin nombre)"); if (!contactId || !locationId) return safeName; const url = `${BUCEFALO_BASE_URL}/v2/location/${locationId}/contacts/detail/${contactId}`; return `${safeName}`; } function branchLink(name, locationId) { const safeName = escapeHtml(name || "—"); if (!name) return safeName; if (!locationId) return safeName; // Bucefalo cambio el listado por defecto a la smart list "All" — sin ese // sufijo el link redirige a una pantalla intermedia/vacia en muchas cuentas. const url = `${BUCEFALO_BASE_URL}/v2/location/${locationId}/contacts/smart_list/All`; return `${safeName}`; } // Pinta la celda "Posibles coincidencias" del bucket missing_in_assigned_branch. // Recibe el array de fuzzy_matches que el audit produce con modalidades permisivas // (teléfono parcial, email canónico/local, primer+apellido). Si está vacío, // devuelve un guion; si tiene matches, muestra advertencia + lista expandible // con enlace directo a cada contacto en su sucursal en Bucéfalo. function renderFuzzyMatchesCell(fuzzyMatches) { if (!Array.isArray(fuzzyMatches) || fuzzyMatches.length === 0) { return ``; } const STRATEGY_BADGE = { "phone_partial": { label: "TEL~", title: "Teléfono parcial (últimos 7 dígitos)", cls: "fuzzy-badge-phone" }, "email_canonical": { label: "MAIL=", title: "Mismo email canónico (gmail sin puntos/alias)", cls: "fuzzy-badge-email-strong" }, "email_local": { label: "MAIL~", title: "Misma parte local del email en otro dominio", cls: "fuzzy-badge-email" }, "first_last": { label: "NOM~", title: "Mismo nombre + primer apellido", cls: "fuzzy-badge-name" }, }; const count = fuzzyMatches.length; const itemsHtml = fuzzyMatches.map(fm => { const badge = STRATEGY_BADGE[fm.strategy] || { label: fm.strategy || "?", title: fm.strategy_label || "", cls: "" }; const fullName = `${fm.first_name || ""} ${fm.last_name || ""}`.trim() || "(sin nombre)"; const branchName = fm.location_name || fm.location_id || "—"; const branchTienda = fm.location_tienda ? ` (TIENDA ${escapeHtml(fm.location_tienda)})` : ""; const contactUrl = (fm.id && fm.location_id) ? `${BUCEFALO_BASE_URL}/v2/location/${fm.location_id}/contacts/detail/${fm.id}` : null; const dataParts = [fm.phone, fm.email].filter(Boolean).map(escapeHtml).join(" · "); const link = contactUrl ? `${escapeHtml(fullName)}` : `${escapeHtml(fullName)}`; return `
  • ${escapeHtml(badge.label)} ${link}
    ${escapeHtml(branchName)}${branchTienda} ${dataParts ? `${dataParts}` : ""}
  • `; }).join(""); return `
    ${count} posible${count === 1 ? "" : "s"} coincidencia${count === 1 ? "" : "s"}
    `; } const COMPARATIVA_BUCKET_COLUMNS = { missing_in_brand: [ // Contacto de sucursal: id = item.id, location = item.branch_location_id { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, it.branch_location_id) }, { key: "phone", label: "Teléfono" }, { key: "email", label: "Email" }, { key: "branch_name", label: "Sucursal" }, { key: "opps_in_branch", label: "Opps locales", align: "right" }, ], missing_in_assigned_branch: [ { key: "_bulk_select", label: "", // Master checkbox vive en el header de la tabla: marca/desmarca todas // las filas visibles en bloque. Es la unica casilla de control. headerRender: (uiKey) => ``, render: (item) => { const expectedLoc = item.expected_location_id || ""; const id = item.id || ""; if (!expectedLoc || !id) { // Sin sucursal resuelta o sin id no es seleccionable para bulk-create. return ``; } // Preserva la marca si el id ya estaba seleccionado antes del re-render. const isChecked = (typeof comparativaBulkSelection !== "undefined" && comparativaBulkSelection["missing_in_assigned_branch"] && comparativaBulkSelection["missing_in_assigned_branch"].has(id)); const checkedAttr = isChecked ? " checked" : ""; return ``; }, }, { key: "_create_in_branch", label: "", render: (item) => { const expectedLoc = item.expected_location_id || ""; const expectedName = item.expected_branch_name || ""; if (!expectedLoc) { return ``; } return ``; }, }, { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) }, { key: "phone", label: "Teléfono" }, { key: "email", label: "Email" }, { key: "tienda", label: "TIENDA" }, { key: "expected_branch_name", label: "Sucursal esperada", render: it => branchLink(it.expected_branch_name, it.expected_location_id) }, { key: "opps_in_brand", label: "Opps en Marca", align: "right" }, { key: "_fuzzy_matches", label: "Posibles coincidencias", render: (item) => renderFuzzyMatchesCell(item.fuzzy_matches || []), }, { key: "_actions", label: "", align: "right", render: (item) => { const cand = item.branch_target_candidate; const cnt = item.branch_target_candidates_count || 0; if (!cand) { const reason = cnt > 1 ? `Hay ${cnt} contactos con el mismo nombre en la sucursal asignada — ambiguo, no se puede actualizar automáticamente.` : "No existe un contacto con el mismo nombre en la sucursal asignada."; return ``; } return ``; }, }, ], present_in_other_branch_not_assigned: [ { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) }, { key: "tienda", label: "TIENDA actual" }, { key: "expected_branch_name", label: "Sucursal esperada", render: it => branchLink(it.expected_branch_name, it.expected_location_id) }, { key: "_real_branches", label: "Está realmente en", render: (it) => { const branches = it.other_branches || []; return branches.map(b => `
    ${escapeHtml(b.name || b.location_id)} · TIENDA: ${escapeHtml(b.tienda_value || "—")}
    `).join(""); }, }, { key: "opps_in_brand", label: "Opps Marca", align: "right" }, { key: "_actions", label: "", align: "right", render: (item) => { const branches = item.other_branches || []; if (!branches.length) return ""; // Pasar JSON de branches en data-attr para que el handler los reciba. const branchesAttr = escapeHtml(JSON.stringify(branches.map(b => ({ location_id: b.location_id, name: b.name, tienda_value: b.tienda_value, id: b.id })))); return ``; }, }, ], probable_duplicate_in_brand: [ { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) }, { key: "phone", label: "Tel. en Marca" }, { key: "email", label: "Email en Marca" }, { key: "tienda", label: "TIENDA" }, { key: "expected_branch_name", label: "Sucursal asignada", render: it => branchLink(it.expected_branch_name, it.expected_location_id) }, { key: "_branch_existing", label: "Contacto bueno en sucursal", render: (it) => { const ex = it.branch_existing_contact || {}; if (!ex.id) return "—"; const fullName = `${ex.first_name || ""} ${ex.last_name || ""}`.trim() || "(sin nombre)"; const tel = ex.phone || "—"; const mail = ex.email || "—"; const link = `${BUCEFALO_BASE_URL}/v2/location/${it.expected_location_id}/contacts/detail/${ex.id}`; return `
    ${escapeHtml(fullName)}
    tel: ${escapeHtml(tel)} · email: ${escapeHtml(mail)}
    `; }, }, { key: "opps_in_brand", label: "Opps en Marca", align: "right" }, { key: "_actions", label: "", align: "right", render: (item) => { return ``; }, }, ], brand_not_in_any_branch: [ // Contacto de Marca { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) }, { key: "phone", label: "Teléfono" }, { key: "email", label: "Email" }, { key: "tienda", label: "TIENDA" }, { key: "sucursal", label: "Sucursal (CF)" }, { key: "expected_branch_name", label: "Sucursal esperada", render: it => { const name = it.expected_branch_name; if (!name) return "—"; const src = it.resolution_source; let badge = ""; if (src === "tienda") { badge = ` T`; } else if (src === "sucursal_exact") { badge = ` S`; } else if (src === "sucursal_substring") { badge = ` S~`; } return `${escapeHtml(name)}${badge}`; }, }, { key: "opps_in_brand", label: "Opps", align: "right", format: v => v == null ? "0" : String(v) }, { key: "_actions", label: "", align: "right", render: (item) => { const expectedLoc = item.expected_location_id || ""; const expectedName = item.expected_branch_name || ""; // Solo mostramos crear/verificar si sabemos a qué sucursal apunta. let actionBtns = ""; if (expectedLoc) { actionBtns += ``; actionBtns += ``; } return `${actionBtns}`; }, }, ], brand_without_tienda: [ { key: "name", label: "Nombre", render: it => { const testBadge = it.looks_like_test ? ` TEST` : ""; return `${contactLink(it.name, it.id, BRAND_LOCATION_ID_UI)}${testBadge}`; }, }, { key: "phone", label: "Teléfono" }, { key: "email", label: "Email" }, { key: "sucursal", label: "Sucursal (CF)", render: it => escapeHtml(it.sucursal || "—") }, { key: "expected_branch_name", label: "Sucursal esperada", render: it => { if (!it.expected_branch_name) { return it.sucursal ? `no resuelta` : ``; } const kind = it.sucursal_resolution_kind; const badge = kind === "exact" ? ` S` : kind === "substring" ? ` S~` : ""; return `${escapeHtml(it.expected_branch_name)}${badge}`; }, }, { key: "expected_tienda", label: "TIENDA a escribir", render: it => it.expected_tienda ? escapeHtml(it.expected_tienda) : `` }, ], brand_unknown_tienda: [ { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) }, { key: "phone", label: "Teléfono" }, { key: "email", label: "Email" }, { key: "tienda", label: "TIENDA" }, ], missing_opps_in_brand: [ { key: "name", label: "Oportunidad" }, { key: "status", label: "Estado" }, { key: "monetary_value", label: "Valor", align: "right", format: v => v ? `$${Number(v).toLocaleString("es-MX", { maximumFractionDigits: 2 })}` : "—" }, // Contacto de sucursal: contact_id + branch_location_id { key: "contact_name", label: "Contacto", render: it => contactLink(it.contact_name, it.contact_id, it.branch_location_id) }, { key: "contact_phone", label: "Tel." }, { key: "branch_name", label: "Sucursal" }, { key: "reason", label: "Motivo" }, ], opps_missing_id_field: [ { key: "location_name", label: "Cuenta", render: it => { const tag = it.is_brand ? `MARCA` : `SUC`; return `${tag} ${escapeHtml(it.location_name || "")}`; }, }, { key: "name", label: "Oportunidad", render: it => contactLink(it.name || "(sin nombre)", it.id, it.location_id) }, { key: "status", label: "Estado" }, { key: "field_value", label: "Valor del campo", render: it => { if (!it.field_value) { return `vacío`; } const len = Number(it.field_len || (it.field_value || "").length); const cls = (len === 20) ? "" : "resolution-test"; const badge = cls ? ` len ${len}` : ""; return `${escapeHtml(String(it.field_value))}${badge}`; }, }, { key: "reason", label: "Motivo" }, { key: "_actions", label: "", align: "right", render: (item) => { if (item.is_brand) { return ``; } return ``; }, }, ], contacts_missing_id_field: [ { key: "location_name", label: "Cuenta", render: it => { const tag = it.is_brand ? `MARCA` : `SUC`; return `${tag} ${escapeHtml(it.location_name || "")}`; }, }, { key: "name", label: "Contacto", render: it => { const full = `${(it.first_name || "").trim()} ${(it.last_name || "").trim()}`.trim() || "(sin nombre)"; return contactLink(full, it.id, it.location_id); }, }, { key: "phone", label: "Tel." }, { key: "email", label: "Email" }, { key: "field_value", label: "Valor del campo", render: it => { if (!it.field_value) { return `vacío`; } const len = Number(it.field_len || (it.field_value || "").length); const cls = (len === 20) ? "" : "resolution-test"; const badge = cls ? ` len ${len}` : ""; return `${escapeHtml(String(it.field_value))}${badge}`; }, }, { key: "reason", label: "Motivo" }, { key: "_actions", label: "", align: "right", render: (item) => { if (item.is_brand) { return `(solo matcheo)`; } return ``; }, }, ], intra_brand_duplicates: [ { key: "name", label: "Nombre", render: it => contactLink(it.name, it.id, BRAND_LOCATION_ID_UI) }, { key: "group_size", label: "Grupo", align: "right", render: it => `${escapeHtml(String(it.group_size || ""))} con mismo nombre` }, { key: "opps_in_brand", label: "Opps", align: "right" }, { key: "date_added", label: "Creado", format: v => v ? new Date(v).toLocaleString("es-MX", { dateStyle: "short", timeStyle: "short" }) : "—" }, { key: "_actions", label: "", align: "right", render: (item) => { const candidates = item.branch_candidates || []; const candidatesAttr = candidates.length ? ` data-branch-candidates='${escapeHtml(JSON.stringify(candidates))}'` : ""; const syncBtn = candidates.length ? `` : ""; return `${syncBtn}`; }, }, ], brand_duplicate_link_opps: [ { key: "recommended_action", label: "", render: it => it.recommended_action === "keep" ? `CONSERVAR` : `BORRAR`, }, { key: "name", label: "Oportunidad", render: it => contactLink(it.name || "(sin nombre)", it.id, BRAND_LOCATION_ID_UI) }, { key: "status", label: "Estado" }, { key: "monetary_value", label: "Valor", align: "right", format: v => v ? `$${Number(v).toLocaleString("es-MX", { maximumFractionDigits: 2 })}` : "—" }, { key: "contact_name", label: "Contacto", render: it => contactLink(it.contact_name, it.contact_id, BRAND_LOCATION_ID_UI) }, { key: "link_value", label: "ID Opp Sucursal (compartido)", render: it => `${escapeHtml(String(it.link_value || ""))}`, }, { key: "group_size", label: "Grupo", align: "right", render: it => `${escapeHtml(String(it.group_size || ""))} apuntan al mismo` }, { key: "branch_name", label: "Origen (sucursal)" }, ], }; const COMPARATIVA_BUCKET_TO_API_KEY = { missing_in_brand: "contacts_in_branch_not_in_brand", missing_in_assigned_branch: "contacts_in_brand_not_in_assigned_branch", present_in_other_branch_not_assigned: "contacts_in_brand_present_in_other_branch_not_assigned", probable_duplicate_in_brand: "contacts_in_brand_probable_duplicate", brand_without_tienda: "contacts_in_brand_without_tienda", brand_unknown_tienda: "contacts_in_brand_with_unknown_tienda", brand_not_in_any_branch: "contacts_in_brand_not_in_any_branch", missing_opps_in_brand: "opportunities_in_branch_not_in_brand", opps_missing_id_field: "opportunities_missing_id_field", brand_duplicate_link_opps: "opportunities_in_brand_duplicate_link", contacts_missing_id_field: "contacts_missing_id_field", intra_brand_duplicates: "intra_brand_duplicates", }; function bindComparativaEvents() { if (comparativaState.bound) return; const reloadBtn = document.getElementById("btn-comparativa-reload"); if (reloadBtn) { reloadBtn.addEventListener("click", () => loadComparativaMarcaSucursales(true)); } const retryBtn = document.getElementById("comparativa-status-retry"); if (retryBtn) { retryBtn.addEventListener("click", () => loadComparativaMarcaSucursales(true)); } document.querySelectorAll(".btn-comparativa-toggle").forEach(btn => { btn.addEventListener("click", () => toggleComparativaBucket(btn.dataset.bucket)); }); document.querySelectorAll(".btn-comparativa-export").forEach(btn => { btn.addEventListener("click", () => exportComparativaBucket(btn.dataset.bucket)); }); comparativaState.bound = true; } // ---- Estados del banner de la comparativa ---- // Tres estados visuales para que el usuario nunca quede mirando "—" sin saber // si está cargando o si falló. El banner vive arriba de las tarjetas de totales // para que sea imposible no verlo. function setComparativaBanner(state, msg) { const banner = document.getElementById("comparativa-status-banner"); const icon = banner ? banner.querySelector(".comparativa-status-icon") : null; const title = document.getElementById("comparativa-status-title"); const message = document.getElementById("comparativa-status-msg"); const retry = document.getElementById("comparativa-status-retry"); if (!banner) return; banner.classList.remove("is-error"); if (state === "loading") { banner.style.display = "flex"; if (icon) icon.innerHTML = ``; if (title) title.textContent = "Calculando comparativa…"; if (message) message.textContent = msg || "Esto puede tomar unos segundos en bases grandes."; if (retry) retry.style.display = "none"; } else if (state === "error") { banner.style.display = "flex"; banner.classList.add("is-error"); if (icon) icon.innerHTML = ``; if (title) title.textContent = "No se pudo cargar la comparativa"; if (message) message.textContent = msg || "Error desconocido. Revisa que haya datos sincronizados."; if (retry) retry.style.display = ""; } else { banner.style.display = "none"; } } // Resetea los valores numéricos al estado de placeholder ("—") y limpia los // badges/desglose. Se llama al iniciar una carga y al fallar el render, para // que un error parcial no deje la UI con datos mezclados (estado viejo + // estado nuevo) que es exactamente lo que confundía al usuario. function resetComparativaUI(label) { const dash = "—"; comparativaSetText("comp-brand-contacts", dash); comparativaSetText("comp-brand-name", label || ""); comparativaSetText("comp-branches-contacts", dash); comparativaSetText("comp-branches-count", dash); comparativaSetText("comp-brand-opps", dash); comparativaSetText("comp-branches-opps", dash); comparativaSetText("comp-contacts-diff", dash); comparativaSetText("comp-opps-diff", dash); comparativaSetText("comp-contacts-diff-status", "Marca − Sucursales"); comparativaSetText("comp-opps-diff-status", "Marca − Sucursales"); Object.keys(COMPARATIVA_BUCKET_TO_API_KEY).forEach(uiKey => { const badge = document.getElementById(`comp-bucket-${uiKey}`); if (badge) badge.textContent = dash; const body = document.getElementById(`comp-body-${uiKey}`); if (body) { body.dataset.rendered = "false"; body.innerHTML = ""; } }); } async function loadComparativaMarcaSucursales(force = false) { bindComparativaEvents(); if (comparativaState.loading) return; if (!force && comparativaState.data) { renderComparativa(comparativaState.data); return; } comparativaState.loading = true; const reloadBtn = document.getElementById("btn-comparativa-reload"); if (reloadBtn) { reloadBtn.disabled = true; reloadBtn.innerHTML = ` Calculando...`; } resetComparativaUI("Cargando…"); setComparativaBanner("loading"); setComparativaPlaceholder("Calculando comparativa... esto puede tomar unos segundos en bases grandes."); try { const res = await fetch(`/api/comparativa/marca-vs-sucursales?limit_missing=2000`); if (!res.ok) { let detail = res.statusText; try { const err = await res.json(); detail = err && err.detail ? err.detail : detail; } catch (_) { /* respuesta no-JSON */ } throw new Error(detail || `HTTP ${res.status}`); } const data = await res.json(); if (!data || !data.totals || !data.totals.brand || !data.totals.branches_aggregate || !data.totals.diff) { throw new Error("La API devolvió un formato inesperado. ¿La base de datos está sincronizada?"); } comparativaState.data = data; try { renderComparativa(data); } catch (renderErr) { console.error("renderComparativa falló:", renderErr); resetComparativaUI("Error al pintar"); throw new Error(`Error al pintar la comparativa: ${renderErr.message}`); } setComparativaBanner("hidden"); const stamp = document.getElementById("comparativa-last-run"); if (stamp) stamp.textContent = `Calculado: ${new Date().toLocaleString("es-MX")}`; } catch (e) { resetComparativaUI("Sin datos"); setComparativaBanner("error", e.message); setComparativaPlaceholder(`Error al cargar la comparativa: ${e.message}`); } finally { comparativaState.loading = false; if (reloadBtn) { reloadBtn.disabled = false; reloadBtn.innerHTML = ` Recalcular`; } } } function setComparativaPlaceholder(msg) { const tbody = document.getElementById("comp-per-branch-rows"); if (tbody) tbody.innerHTML = `${msg}`; } function fmtNumber(n) { return Number(n || 0).toLocaleString("es-MX"); } function renderComparativa(data) { const t = data.totals || {}; const b = t.brand || {}; const ag = t.branches_aggregate || {}; const d = t.diff || {}; // Totales comparativaSetText("comp-brand-contacts", fmtNumber(b.contacts)); comparativaSetText("comp-brand-name", b.name || "—"); comparativaSetText("comp-branches-contacts", fmtNumber(ag.contacts)); comparativaSetText("comp-branches-count", `${ag.branch_count || 0} sucursales activas`); comparativaSetText("comp-brand-opps", fmtNumber(b.opportunities)); comparativaSetText("comp-branches-opps", fmtNumber(ag.opportunities)); const cDiff = Number(d.contacts || 0); const oDiff = Number(d.opportunities || 0); comparativaSetText("comp-contacts-diff", (cDiff > 0 ? "+" : "") + fmtNumber(cDiff)); comparativaSetText("comp-opps-diff", (oDiff > 0 ? "+" : "") + fmtNumber(oDiff)); comparativaSetText("comp-contacts-diff-status", d.contacts_match ? "OK · totales coinciden" : "Descuadre detectado"); comparativaSetText("comp-opps-diff-status", d.opportunities_match ? "OK · totales coinciden" : "Descuadre detectado"); const cCard = document.getElementById("comp-contacts-diff-card"); const oCard = document.getElementById("comp-opps-diff-card"); if (cCard) cCard.classList.toggle("is-ok", d.contacts_match); if (cCard) cCard.classList.toggle("is-mismatch", !d.contacts_match); if (oCard) oCard.classList.toggle("is-ok", d.opportunities_match); if (oCard) oCard.classList.toggle("is-mismatch", !d.opportunities_match); // Demos excluidas const demos = data.demos_excluded || []; const demosCard = document.getElementById("comp-demos-card"); const demosList = document.getElementById("comp-demos-list"); if (demos.length && demosCard && demosList) { demosCard.style.display = ""; demosList.innerHTML = demos .map(dx => `
  • ${escapeHtml(dx.name)} (${dx.location_id})
  • `) .join(""); } else if (demosCard) { demosCard.style.display = "none"; } // Buckets Object.entries(COMPARATIVA_BUCKET_TO_API_KEY).forEach(([uiKey, apiKey]) => { const bucket = (data.missing || {})[apiKey] || { total: 0, items: [], truncated: false }; const badge = document.getElementById(`comp-bucket-${uiKey}`); if (badge) badge.textContent = fmtNumber(bucket.total); const body = document.getElementById(`comp-body-${uiKey}`); if (body) { body.dataset.rendered = "false"; body.innerHTML = ""; // Si el bucket esta abierto, recalcula su contenido. if (!body.hasAttribute("hidden")) { renderComparativaBucketBody(uiKey); } } }); // Desglose por sucursal const tbody = document.getElementById("comp-per-branch-rows"); if (tbody) { const rows = data.per_branch || []; if (!rows.length) { tbody.innerHTML = `No hay sucursales para mostrar.`; } else { tbody.innerHTML = rows.map((r, idx) => ` ${idx + 1} ${escapeHtml(r.name)} ${fmtNumber(r.contacts)} ${fmtNumber(r.opportunities)} `).join(""); } } } function toggleComparativaBucket(uiKey) { const body = document.getElementById(`comp-body-${uiKey}`); if (!body) return; const open = body.hasAttribute("hidden"); if (open) { body.removeAttribute("hidden"); renderComparativaBucketBody(uiKey); } else { body.setAttribute("hidden", ""); } // Cambiar icono del boton (toggle de chevron) const btn = document.querySelector(`.btn-comparativa-toggle[data-bucket="${uiKey}"]`); if (btn) { btn.innerHTML = open ? ` Ocultar listado` : ` Ver listado`; } } // ---- Bulk selection para buckets de Comparativa ---- // Mantiene el conjunto de brand_contact_ids seleccionados por bucket. const comparativaBulkSelection = { // bucket_uiKey -> Set }; function getBulkSelectionSet(uiKey) { if (!comparativaBulkSelection[uiKey]) comparativaBulkSelection[uiKey] = new Set(); return comparativaBulkSelection[uiKey]; } function clearBulkSelection(uiKey) { comparativaBulkSelection[uiKey] = new Set(); } function refreshBulkSelectionUI(uiKey) { const body = document.getElementById(`comp-body-${uiKey}`); if (!body) return; const sel = getBulkSelectionSet(uiKey); // Master checkbox (vive en el de la primera columna): marcado si todas // las filas visibles seleccionables están en la selección; indeterminado // si hay marcadas parciales. const master = body.querySelector(`.comp-bulk-master-checkbox[data-bucket="${uiKey}"]`); const visibleSelectable = Array.from(body.querySelectorAll(`tbody tr`)) .filter(tr => tr.style.display !== "none") .map(tr => tr.querySelector(`input.comp-bulk-row-checkbox:not([disabled])`)) .filter(Boolean); if (master) { if (visibleSelectable.length === 0) { master.checked = false; master.indeterminate = false; } else { const checkedCount = visibleSelectable.filter(cb => cb.checked).length; master.checked = checkedCount === visibleSelectable.length; master.indeterminate = checkedCount > 0 && checkedCount < visibleSelectable.length; } } // Botón primario del header del bucket muestra el contador inline. const headerBtn = document.getElementById("btn-sync-missing-in-assigned-branch"); if (headerBtn && uiKey === "missing_in_assigned_branch") { if (sel.size === 0) { headerBtn.disabled = true; headerBtn.innerHTML = ` Crear seleccionados en sucursal`; } else { headerBtn.disabled = false; headerBtn.innerHTML = ` Crear seleccionados en sucursal (${sel.size})`; } } } function bindBulkSelectionEvents(uiKey, body) { if (body.dataset.bulkBound === "true") return; body.dataset.bulkBound = "true"; body.addEventListener("change", (e) => { const target = e.target; if (!target) return; // Checkbox por fila if (target.matches(`input.comp-bulk-row-checkbox[data-bucket="${uiKey}"]`)) { const id = target.dataset.brandContactId; if (!id) return; const sel = getBulkSelectionSet(uiKey); if (target.checked) sel.add(id); else sel.delete(id); refreshBulkSelectionUI(uiKey); } // Master checkbox (vive en el de la columna de seleccion) if (target.matches(`input.comp-bulk-master-checkbox[data-bucket="${uiKey}"]`)) { const checked = target.checked; const sel = getBulkSelectionSet(uiKey); const visibleRows = Array.from(body.querySelectorAll(`tbody tr`)) .filter(tr => tr.style.display !== "none"); visibleRows.forEach(tr => { const cb = tr.querySelector(`input.comp-bulk-row-checkbox:not([disabled])`); if (!cb) return; cb.checked = checked; const id = cb.dataset.brandContactId; if (!id) return; if (checked) sel.add(id); else sel.delete(id); }); refreshBulkSelectionUI(uiKey); } }); // Estado inicial. refreshBulkSelectionUI(uiKey); } function renderComparativaBucketBody(uiKey) { const body = document.getElementById(`comp-body-${uiKey}`); if (!body) return; if (body.dataset.rendered === "true") return; const data = comparativaState.data; if (!data) { body.innerHTML = `

    Sin datos. Recalcula la comparativa.

    `; return; } const apiKey = COMPARATIVA_BUCKET_TO_API_KEY[uiKey]; const bucket = (data.missing || {})[apiKey] || { total: 0, items: [], truncated: false }; const cols = COMPARATIVA_BUCKET_COLUMNS[uiKey] || []; if (!bucket.items.length) { body.innerHTML = `

    No hay registros en este grupo. ¡Limpio!

    `; body.dataset.rendered = "true"; return; } const truncatedNote = bucket.truncated ? `

    Mostrando los primeros ${bucket.items.length} de ${bucket.total}. Exporta a CSV para verlos todos.

    ` : ""; const filterId = `comp-filter-${uiKey}`; const mainTableHtml = `
    ${cols.map(c => { const thStyle = c.align ? ` style="text-align:${c.align}"` : ""; // Las columnas pueden definir headerRender(uiKey) para inyectar // controles en el `; }).join("")} ${bucket.items.map(item => ` ${cols.map(c => { const tdStyle = c.align ? ` style="text-align:${c.align}"` : ""; if (typeof c.render === "function") { return `${c.render(item)}`; } let v = item[c.key]; if (typeof c.format === "function") v = c.format(v); if (v === undefined || v === null || v === "") v = "—"; const cell = escapeHtml(String(v)); return `${cell}`; }).join("")} `).join("")}
    (p.ej. master-checkbox de bulk select). const thContent = (typeof c.headerRender === "function") ? c.headerRender(uiKey) : c.label; return `${thContent}
    `; // Para brand_without_tienda añadimos una sub-sección con los contactos detectados // como prueba/test. El delete reutiliza el endpoint existente // /api/comparativa/contact (mismo flujo que .btn-micro-delete[data-action=delete-brand-contact]). let testSectionHtml = ""; if (uiKey === "brand_without_tienda") { const testItems = bucket.items.filter(it => it.looks_like_test); if (testItems.length > 0) { testSectionHtml = `
    Detectados como prueba/test ${testItems.length}

    Contactos cuyo nombre, email o teléfono contiene test, testing, prueba o pruebas. Elimínalos si confirmas que no son leads reales.

    ${testItems.map(it => ` `).join("")}
    Nombre Teléfono Email Sucursal (CF)
    ${contactLink(it.name, it.id, BRAND_LOCATION_ID_UI)} TEST ${escapeHtml(it.phone || "—")} ${escapeHtml(it.email || "—")} ${escapeHtml(it.sucursal || "—")}
    `; } } // Depuracion defensiva del set de seleccion: si los datos se refrescaron // (apply en otro bucket, reload manual), descarta IDs que ya no existen. if (uiKey === "missing_in_assigned_branch") { const sel = getBulkSelectionSet(uiKey); if (sel.size) { const stillExisting = new Set(bucket.items.map(it => it.id).filter(Boolean)); Array.from(sel).forEach(id => { if (!stillExisting.has(id)) sel.delete(id); }); } } body.innerHTML = ` ${truncatedNote}
    ${mainTableHtml} ${testSectionHtml} `; const input = document.getElementById(filterId); if (input) { input.addEventListener("input", () => { const term = input.value.trim().toLowerCase(); body.querySelectorAll("tbody tr").forEach(tr => { tr.style.display = !term || tr.textContent.toLowerCase().includes(term) ? "" : "none"; }); // Si la fila se oculta, no entra como "visible" para el master-checkbox. refreshBulkSelectionUI(uiKey); }); } // Listener delegado para botones de accion dentro del bucket body. // Solo bindeo una vez por body (dataset.actionsBound). if (body.dataset.actionsBound !== "true") { body.addEventListener("click", handleComparativaBucketAction); body.dataset.actionsBound = "true"; } // Bindeo de bulk-selection solo para los buckets que la soportan. if (uiKey === "missing_in_assigned_branch") { bindBulkSelectionEvents(uiKey, body); // Re-render: sincroniza UI con el set en memoria (contador, master, botón header). refreshBulkSelectionUI(uiKey); } body.dataset.rendered = "true"; } function updateIntraBrandDuplicateGroup(body, nameNorm) { if (!body || !nameNorm) return; const escSel = window.CSS && CSS.escape ? CSS.escape(nameNorm) : nameNorm.replace(/"/g, '\\"'); const remainingBtns = body.querySelectorAll(`.btn-micro-delete[data-name-norm="${escSel}"]`); const trsInGroup = []; remainingBtns.forEach(b => { const tr = b.closest("tr"); if (tr) trsInGroup.push(tr); }); const count = trsInGroup.length; trsInGroup.forEach(tr => { const groupSpan = tr.querySelector(".group-count"); const btn = tr.querySelector(".btn-micro-delete"); const syncBtn = tr.querySelector(".btn-micro-sync-from-branch"); if (count <= 1) { if (groupSpan) { groupSpan.textContent = "Único restante"; groupSpan.classList.add("group-count--last"); } if (btn) { btn.disabled = true; btn.title = "Es el único restante del grupo — ya no es un duplicado"; btn.classList.add("is-locked"); } // Si hay candidatos sucursal, revelar el sync btn. if (syncBtn) { syncBtn.hidden = false; syncBtn.title = "Sincronizar desde sucursal: actualiza este contacto Marca con todos los datos y custom fields del contacto sucursal con mismo nombre."; } } else { if (groupSpan) { groupSpan.textContent = `${count} con mismo nombre`; groupSpan.classList.remove("group-count--last"); } // Si vuelve a haber más, ocultar el sync btn. if (syncBtn) syncBtn.hidden = true; } }); } async function handleUpdateBranchFromBrandClick(btn) { const brandContactId = btn.dataset.brandContactId; const branchContactId = btn.dataset.branchContactId; const branchLocationId = btn.dataset.branchLocationId; const branchName = btn.dataset.branchName || "(sucursal)"; const name = btn.dataset.name || "(sin nombre)"; const ok = await appConfirm({ title: "Actualizar contacto en sucursal", severity: "warning", message: `Vas a actualizar el contacto ${escapeHtmlAttr(name)} que está en ${escapeHtmlAttr(branchName)} con los datos y custom fields del contacto Marca.

    Esto pisa los campos del contacto en sucursal con los del Marca.`, confirmText: "Actualizar", }); if (!ok) return; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { const res = await mutateFetch("/api/comparativa/update-branch-from-brand", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand_contact_id: brandContactId, branch_contact_id: branchContactId, branch_location_id: branchLocationId, }), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); btn.innerHTML = ''; btn.title = `Actualizado: ${data.custom_fields_count} custom fields sincronizados.`; btn.classList.add("is-done"); if (typeof comparativaState !== "undefined") comparativaState.data = null; } catch (err) { showToast(`Error: ${err.message}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; } } /** * Verifica si el contacto Marca tiene un homónimo EXACTO en la sucursal esperada. * Read-only: solo consulta SQLite. Si match, refresca la tabla (el contacto * debería desaparecer del bucket brand_not_in_any_branch automáticamente). */ async function handleVerifyInExpectedBranchClick(btn) { const brandContactId = btn.dataset.brandContactId; const expectedLocationId = btn.dataset.expectedLocationId; const expectedBranchName = btn.dataset.expectedBranchName || expectedLocationId; const name = btn.dataset.name || "(sin nombre)"; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { // Verify es read-only — usamos fetch directo (no necesita modo dry-run/live). const res = await fetch("/api/comparativa/verify-in-expected-branch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand_contact_id: brandContactId, expected_location_id: expectedLocationId }), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); if (data.found) { const matchesHtml = (data.matches || []).map(m => `
  • ${escapeHtml(`${m.first_name || ""} ${m.last_name || ""}`.trim() || "(sin nombre)")} — ${escapeHtml(m.email || "sin email")} — ${escapeHtml(m.phone || "sin tel")}
    ${escapeHtml(m.id)}
  • ` ).join(""); await appConfirm({ title: "Encontrado en la sucursal esperada", severity: "info", message: ` Se encontraron ${data.match_count} match(es) EXACTO(s) para ${escapeHtml(name)} en ${escapeHtml(expectedBranchName)}: Esto significa que el audit Marca↔Sucursal probablemente no lo cruzó por phone/email distintos. Al refrescar la comparativa, este contacto debería salir del bucket "sin contraparte". `, confirmText: "Refrescar comparativa", cancelText: "Cerrar", }).then(refresh => { if (refresh && typeof loadComparativaMarcaSucursales === "function") { if (typeof comparativaState !== "undefined") comparativaState.data = null; loadComparativaMarcaSucursales(true); } }); btn.innerHTML = ''; btn.title = `Encontrado en ${expectedBranchName}.`; btn.classList.add("is-done"); } else { await appConfirm({ title: "Sin homónimo exacto", severity: "warning", message: ` No se encontró un contacto con nombre EXACTO "${escapeHtml(name)}" en ${escapeHtml(expectedBranchName)}.

    Posibles razones: `, confirmText: "Entendido", cancelText: "Cerrar", }); btn.disabled = false; btn.innerHTML = originalHTML; } } catch (err) { showToast(`Error al verificar: ${err.message}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; } } /** * Crea el contacto Marca en la sucursal esperada (POST contact con CFs mapeados). * Respeta el toggle global Dry-Run/Live via mutateFetch. Tras crear, refresca * la comparativa para que el contacto salga del bucket. */ /** * Click en "Llenar" (botón por-item) del bucket "opps_missing_id_field". * Abre el modal genérico de sync acotando a esa única opp para validar y aplicar. */ async function handleFillOppIdClick(btn) { const oppId = btn.dataset.oppId; if (!oppId) return; openSyncBucketModal("opps_missing_id_field", { contactIds: [oppId] }); } /** * Click en "Matchear" (botón por-item para una opp Marca) del bucket * "opps_missing_id_field". Abre el modal genérico apuntando al bucket sintético * que rutea al endpoint /api/comparativa/match-brand-opp-id-sucursal. */ async function handleMatchBrandOppIdClick(btn) { const oppId = btn.dataset.oppId; if (!oppId) return; openSyncBucketModal("opps_missing_id_field_brand", { contactIds: [oppId] }); } /** * Click en "Llenar" (botón por-item) del bucket "contacts_missing_id_field". */ async function handleFillContactIdClick(btn) { const contactId = btn.dataset.contactId; if (!contactId) return; openSyncBucketModal("contacts_missing_id_field", { contactIds: [contactId] }); } async function handleCreateInExpectedBranchClick(btn) { const brandContactId = btn.dataset.brandContactId; const expectedLocationId = btn.dataset.expectedLocationId; const expectedBranchName = btn.dataset.expectedBranchName || expectedLocationId; const name = btn.dataset.name || "(sin nombre)"; const isLive = window.AppMode.isLive; const ok = await appConfirm({ title: isLive ? "Crear contacto en sucursal (LIVE)" : "Crear contacto en sucursal (Simulación)", severity: isLive ? "danger" : "info", message: ` Vas a crear el contacto ${escapeHtml(name)} en la sucursal ${escapeHtml(expectedBranchName)} (${escapeHtml(expectedLocationId)}).

    Se copian first/last name, email, phone y todos los custom fields mapeados por nombre desde el schema Marca al schema de la sucursal.

    ${isLive ? "Atención: estás en modo LIVE. Se creará de verdad en Bucéfalo. Si ya existe homónimo por email/teléfono, GHL rechazará la creación." : "Estás en modo Simulación. Confirmar solo cierra este preview — nada se aplicará. Cambia a Live para crear de verdad." } `, confirmText: isLive ? "Sí, crear en sucursal" : "Entendido (no aplica)", cancelText: "Cancelar", }); if (!ok) return; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { const res = await mutateFetch("/api/comparativa/create-in-expected-branch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand_contact_id: brandContactId, expected_location_id: expectedLocationId }), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(typeof err.detail === "string" ? err.detail : JSON.stringify(err.detail)); } const data = await res.json(); if (data.dry_run) { showToast(`Modo Simulación: se crearía "${name}" en ${expectedBranchName}.`, "info"); btn.disabled = false; btn.innerHTML = originalHTML; return; } showToast(`Contacto "${name}" creado en ${expectedBranchName}. ID: ${data.branch_contact_id}. run_id=${data.run_id || "—"}`, "success"); btn.innerHTML = ''; btn.classList.add("is-done"); if (typeof comparativaState !== "undefined") comparativaState.data = null; if (typeof loadComparativaMarcaSucursales === "function") { loadComparativaMarcaSucursales(true); } } catch (err) { showToast(`Error al crear en sucursal: ${err.message}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; } } async function handleUpdateBrandTiendaClick(btn) { const brandContactId = btn.dataset.brandContactId; const name = btn.dataset.name || "(sin nombre)"; let branches = []; try { branches = JSON.parse(btn.dataset.branches || "[]"); } catch (e) { branches = []; } if (!branches.length) { showToast("No hay sucursales destino para usar.", "error"); return; } let chosen = branches[0]; if (branches.length > 1) { const idx = await appSelectFromList({ title: "Elige la sucursal de referencia", message: `${escapeHtmlAttr(name)} aparece en ${branches.length} sucursales. Selecciona aquella cuya TIENDA se aplicará al contacto Marca:`, options: branches.map(b => ({ label: b.name, sublabel: `TIENDA: ${b.tienda_value || "—"}`, })), confirmText: "Usar esta sucursal", }); if (idx === null) return; chosen = branches[idx]; } if (!chosen.tienda_value) { showToast(`La sucursal ${chosen.name} no tiene un valor TIENDA en el verificador. No se puede actualizar.`, "error"); return; } const ok = await appConfirm({ title: "Cambiar TIENDA del contacto Marca", severity: "warning", message: `Vas a cambiar el valor TIENDA del contacto Marca ${escapeHtmlAttr(name)} a ${escapeHtmlAttr(chosen.tienda_value)} (sucursal ${escapeHtmlAttr(chosen.name)}).

    Esto corrige el mapeo del contacto a la sucursal donde realmente está.`, confirmText: "Actualizar TIENDA", }); if (!ok) return; const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { const res = await mutateFetch("/api/comparativa/update-brand-tienda", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand_contact_id: brandContactId, new_tienda_value: chosen.tienda_value, }), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } await res.json(); btn.innerHTML = ''; btn.title = `TIENDA actualizada a "${chosen.tienda_value}".`; btn.classList.add("is-done"); if (typeof comparativaState !== "undefined") comparativaState.data = null; } catch (err) { showToast(`Error: ${err.message}`, "error"); btn.disabled = false; btn.innerHTML = originalHTML; } } async function handleSyncFromBranchClick(syncBtn) { const brandContactId = syncBtn.dataset.brandContactId; const name = syncBtn.dataset.name || "(sin nombre)"; let candidates = []; try { candidates = JSON.parse(syncBtn.dataset.branchCandidates || "[]"); } catch (e) { candidates = []; } if (!candidates.length) { showToast("No hay candidatos en sucursal para sincronizar.", "error"); return; } let chosen = candidates[0]; if (candidates.length > 1) { const idx = await appSelectFromList({ title: "Elige el contacto en sucursal", message: `Hay ${candidates.length} candidatos en sucursal para ${escapeHtmlAttr(name)}. Selecciona cuál se usará como fuente:`, options: candidates.map(c => ({ label: c.branch_name, sublabel: `${c.opps_count} oportunidad(es)`, })), confirmText: "Usar este contacto", }); if (idx === null) return; chosen = candidates[idx]; } const ok = await appConfirm({ title: "Sincronizar contacto Marca desde sucursal", severity: "warning", message: `Vas a actualizar el contacto Marca ${escapeHtmlAttr(name)} con los datos y custom fields del contacto en ${escapeHtmlAttr(chosen.branch_name)}.

    Esto pisa los campos del contacto Marca con los de sucursal (los CFs en Marca que no existen en sucursal se conservan).`, confirmText: "Sincronizar", }); if (!ok) return; const originalHTML = syncBtn.innerHTML; syncBtn.disabled = true; syncBtn.innerHTML = ''; try { const res = await mutateFetch("/api/comparativa/sync-from-branch-contact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ brand_contact_id: brandContactId, branch_contact_id: chosen.id, branch_location_id: chosen.location_id, }), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); syncBtn.innerHTML = ''; syncBtn.title = `Sincronizado desde ${chosen.branch_name} (${data.custom_fields_count} custom fields actualizados).`; syncBtn.classList.add("is-done"); if (typeof comparativaState !== "undefined") comparativaState.data = null; } catch (err) { showToast(`Error: ${err.message}`, "error"); syncBtn.disabled = false; syncBtn.innerHTML = originalHTML; } } async function handleComparativaBucketAction(e) { const syncBtn = e.target.closest(".btn-micro-sync-from-branch"); if (syncBtn && syncBtn.dataset.action === "sync-from-branch") { await handleSyncFromBranchClick(syncBtn); return; } if (syncBtn && syncBtn.dataset.action === "update-branch-from-brand") { await handleUpdateBranchFromBrandClick(syncBtn); return; } if (syncBtn && syncBtn.dataset.action === "update-brand-tienda") { await handleUpdateBrandTiendaClick(syncBtn); return; } // Botones nuevos del bucket brand_not_in_any_branch. const verifyBtn = e.target.closest(".btn-micro-verify"); if (verifyBtn && verifyBtn.dataset.action === "verify-in-expected-branch") { await handleVerifyInExpectedBranchClick(verifyBtn); return; } const createInBranchBtn = e.target.closest(".btn-micro-create-in-branch"); if (createInBranchBtn && createInBranchBtn.dataset.action === "create-in-expected-branch") { await handleCreateInExpectedBranchClick(createInBranchBtn); return; } const fillOppIdBtn = e.target.closest(".btn-micro-fill-opp-id"); if (fillOppIdBtn && fillOppIdBtn.dataset.action === "fill-opp-id") { await handleFillOppIdClick(fillOppIdBtn); return; } const matchBrandOppIdBtn = e.target.closest(".btn-micro-match-brand-opp-id"); if (matchBrandOppIdBtn && matchBrandOppIdBtn.dataset.action === "match-brand-opp-id") { await handleMatchBrandOppIdClick(matchBrandOppIdBtn); return; } const fillContactIdBtn = e.target.closest(".btn-micro-fill-contact-id"); if (fillContactIdBtn && fillContactIdBtn.dataset.action === "fill-contact-id") { await handleFillContactIdClick(fillContactIdBtn); return; } const delBtn = e.target.closest(".btn-micro-delete"); if (delBtn && delBtn.dataset.action === "delete-brand-contact") { const contactId = delBtn.dataset.contactId; const name = delBtn.dataset.name || "(sin nombre)"; const opps = parseInt(delBtn.dataset.opps, 10) || 0; const oppsClause = opps > 0 ? `Tiene ${opps} oportunidad${opps !== 1 ? "es" : ""} asociada${opps !== 1 ? "s" : ""} que también será${opps !== 1 ? "n" : ""} eliminada${opps !== 1 ? "s" : ""}.

    ` : ""; const ok = await appConfirm({ title: "Eliminar contacto Marca", severity: "danger", message: `¿Eliminar ${escapeHtmlAttr(name)} del CRM de Marca?

    ${oppsClause}Esta acción es IRREVERSIBLE.`, confirmText: "Eliminar definitivamente", }); if (!ok) return; const originalHTML = delBtn.innerHTML; delBtn.disabled = true; delBtn.innerHTML = ''; try { const url = `/api/comparativa/contact?contact_id=${encodeURIComponent(contactId)}&location_id=GbKkBpCmKu2QmloKFHy3`; const res = await mutateFetch(url, { method: "DELETE" }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); // Remover fila visualmente const tr = delBtn.closest("tr"); const containerBody = tr ? tr.closest(".comparativa-bucket-body") : null; const nameNorm = delBtn.dataset.nameNorm || ""; if (tr) tr.remove(); // Actualizar badge del bucket al que pertenece la fila eliminada (-1). // El bucket se identifica por el id del body (ej. "comp-body-intra_brand_duplicates"). if (containerBody && containerBody.id) { const bucketKey = containerBody.id.replace(/^comp-body-/, ""); const badge = document.getElementById(`comp-bucket-${bucketKey}`); if (badge) { const n = parseInt(badge.textContent.replace(/\D/g, ""), 10) || 0; if (n > 0) badge.textContent = (n - 1).toLocaleString("es-MX"); } // Si es el bucket de duplicados, actualizar contadores del grupo // y deshabilitar el delete del ultimo restante. if (bucketKey === "intra_brand_duplicates" && nameNorm) { updateIntraBrandDuplicateGroup(containerBody, nameNorm); } } // Invalidar cache del state para que el próximo "Recalcular" pida data fresh. if (typeof comparativaState !== "undefined") comparativaState.data = null; } catch (err) { showToast(`Error al eliminar: ${err.message}`, "error"); delBtn.disabled = false; delBtn.innerHTML = originalHTML; } } } function exportComparativaBucket(uiKey) { window.open(`/api/comparativa/marca-vs-sucursales/export?bucket=${encodeURIComponent(uiKey)}`, "_blank"); } function comparativaSetText(id, value) { const el = document.getElementById(id); if (el) el.textContent = value; } // ========================================================================== // SYNC TO BRAND (boton + modal generico para distintos buckets) // ========================================================================== const SYNC_BUCKETS_CONFIG = { "missing_opps_in_brand": { triggerBtnId: "btn-sync-missing-opps", endpoint: "/api/comparativa/sync-missing-opps", modalTitleIcon: "fa-rotate", bucketTitle: "opps faltantes en Marca", applyNoteHtml: "Escribiendo en GHL. Esto puede demorar (10s por cada contacto nuevo creado).", emptyMsg: "No hay oportunidades pendientes de sincronizar a Marca. ¡Todo limpio!", summaryFields: (s, isApplyResult) => [ { label: "Candidatas", value: s.candidates }, { label: isApplyResult ? "Contactos creados" : "Contactos a crear", value: s.contacts_created }, { label: isApplyResult ? "Creadas (opps)" : "A crear (opps)", value: s.opps_created }, { label: isApplyResult ? "Actualizadas (opps)" : "A actualizar (opps)", value: s.opps_updated }, { label: "Errores", value: s.errors, danger: !!s.errors }, ], columns: ["Acción", "Oportunidad", "Sucursal", "Contacto", "Operaciones"], renderRow: (it, isApplyResult) => { const statusClass = it.status === "error" ? "is-danger" : it.status === "created" ? "is-create" : it.status === "updated" ? "is-update" : ""; const statusLabel = { "created": isApplyResult ? "Creada" : "Crear", "updated": isApplyResult ? "Actualizada" : "Actualizar", "error": "Error", "pending": "Pendiente", }[it.status] || it.status; const contactName = (it.branch_contact && it.branch_contact.name) || "(sin contacto)"; const contactRef = (it.branch_contact && (it.branch_contact.phone || it.branch_contact.email)) || ""; const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const actionsHTML = (it.actions || []).map(a => { if (a.action === "match_existing_contact") return `match contacto (por ${escapeHtml(a.strategy)})`; if (a.action === "create_contact") return `crear contacto`; if (a.action === "create_opp") return `crear opp`; if (a.action === "update_opp") return `actualizar opp ${escapeHtml(a.brand_opp_id || "")}`; return `${escapeHtml(a.action || "")}`; }).join(" "); return ` ${statusLabel} ${escapeHtml(it.opp_name || "(sin nombre)")}
    ${escapeHtml(it.opp_id)} ${escapeHtml(it.branch_name)} ${escapeHtml(contactName)}
    ${escapeHtml(contactRef)} ${actionsHTML}${errorHTML} `; }, }, "missing_in_brand": { triggerBtnId: "btn-sync-missing-contacts", endpoint: "/api/comparativa/sync-missing-contacts", modalTitleIcon: "fa-user-plus", bucketTitle: "contactos faltantes en Marca", applyNoteHtml: "Creando contactos en Marca. Cada uno se verifica por teléfono → email → nombre antes de crear.", emptyMsg: "No hay contactos pendientes de sincronizar a Marca. ¡Todo limpio!", summaryFields: (s, isApplyResult) => [ { label: "Candidatos", value: s.candidates }, { label: isApplyResult ? "Creados" : "A crear", value: s.contacts_created }, { label: "Ya existían en Marca", value: s.skipped_already_in_brand || 0 }, { label: "Errores", value: s.errors, danger: !!s.errors }, ], columns: ["Acción", "Contacto", "Sucursal", "Datos", "Operaciones"], renderRow: (it, isApplyResult) => { const statusClass = it.status === "error" ? "is-danger" : it.status === "created" ? "is-create" : it.status === "skipped" ? "is-skip" : ""; const statusLabel = { "created": isApplyResult ? "Creado" : "Crear", "skipped": "Ya existe", "error": "Error", "pending": "Pendiente", }[it.status] || it.status; const dataParts = [it.phone, it.email].filter(Boolean).join(" / "); const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const actionsHTML = (it.actions || []).map(a => { if (a.action === "skip_already_in_brand") return `ya existe (por ${escapeHtml(a.strategy)})`; if (a.action === "create_contact") return `crear contacto`; return `${escapeHtml(a.action || "")}`; }).join(" "); return ` ${statusLabel} ${escapeHtml(it.name || "(sin nombre)")}
    ${escapeHtml(it.branch_contact_id || "")} ${escapeHtml(it.branch_name)} ${escapeHtml(dataParts || "—")}${it.opps_in_branch ? `
    ${it.opps_in_branch} opps locales` : ""} ${actionsHTML}${errorHTML} `; }, }, "brand_without_tienda": { triggerBtnId: "btn-fix-tienda-from-sucursal", endpoint: "/api/comparativa/fix-tienda-from-sucursal", modalTitleIcon: "fa-wand-magic-sparkles", bucketTitle: "rellenar TIENDA desde Sucursal", applyNoteHtml: "Escribiendo el campo TIENDA en Marca a partir del valor de Sucursal resuelto via verificador.", emptyMsg: "No hay contactos con Sucursal resoluble pendientes. Los demás necesitan revisión manual.", summaryFields: (s, isApplyResult) => [ { label: "Candidatos", value: s.candidates }, { label: isApplyResult ? "Actualizados" : "A actualizar", value: s.contacts_updated }, { label: "Skip · sin Sucursal", value: s.skipped_no_sucursal || 0 }, { label: "Skip · Sucursal sin match", value: s.skipped_unresolved_sucursal || 0 }, { label: "Skip · TIENDA ya poblada", value: s.skipped_already_has_tienda || 0 }, { label: "Errores", value: s.errors, danger: !!s.errors }, ], columns: ["Acción", "Contacto Marca", "Sucursal (CF)", "Sucursal esperada", "TIENDA a escribir", "Operaciones"], renderRow: (it, isApplyResult) => { const statusClass = it.status === "error" ? "is-danger" : it.status === "updated" ? "is-update" : (it.status || "").startsWith("skipped") ? "is-skip" : ""; const statusLabel = { "updated": isApplyResult ? "Actualizado" : "Actualizar", "skipped_no_sucursal": "Sin Sucursal", "skipped_unresolved_sucursal": "Sucursal sin match", "skipped_already_has_tienda": "Ya tenía TIENDA", "error": "Error", "pending": "Pendiente", }[it.status] || it.status; const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const actionsHTML = (it.actions || []).map(a => { if (a.action === "skip_no_sucursal") return `sin Sucursal`; if (a.action === "skip_unresolved_sucursal") return `Sucursal '${escapeHtml(a.sucursal || "")}' no mapea`; if (a.action === "skip_already_has_tienda") return `ya tenía TIENDA='${escapeHtml(a.current_tienda || "")}'`; if (a.action === "update_tienda") { const kind = a.resolution_kind === "substring" ? " (substring)" : ""; return `escribir TIENDA${kind}`; } return `${escapeHtml(a.action || "")}`; }).join(" "); return ` ${statusLabel} ${escapeHtml(it.name || "(sin nombre)")}
    ${escapeHtml(it.brand_contact_id || "")} ${escapeHtml(it.sucursal || "—")} ${escapeHtml(it.expected_branch_name || "—")} ${escapeHtml(it.expected_tienda || "—")} ${actionsHTML}${errorHTML} `; }, }, "brand_not_in_any_branch": { triggerBtnId: "btn-sync-brand-to-branch", endpoint: "/api/comparativa/sync-brand-to-branch-contacts", modalTitleIcon: "fa-arrow-right-arrow-left", bucketTitle: "contactos de Marca → Sucursal", applyNoteHtml: "Creando contactos en sus sucursales correspondientes. Antes de crear se verifica en la sucursal destino Y globalmente en todas las demás. Los creates corren en paralelo.", emptyMsg: "No hay contactos pendientes de propagar a sucursales. ¡Todo limpio!", summaryFields: (s, isApplyResult) => [ { label: "Candidatos", value: s.candidates }, { label: isApplyResult ? "Creados en sucursal" : "A crear en sucursal", value: s.contacts_created }, { label: "Ya existían en destino", value: s.skipped_already_in_branch || 0 }, { label: "Ya existían en OTRA sucursal", value: s.skipped_in_other_branch || 0 }, { label: "Skip · sin TIENDA", value: s.skipped_no_tienda || 0 }, { label: "Skip · TIENDA desconocida", value: s.skipped_unknown_tienda || 0 }, { label: "Errores", value: s.errors, danger: !!s.errors }, ], columns: ["Acción", "Contacto Marca", "TIENDA", "Sucursal destino", "Datos", "Operaciones"], renderRow: (it, isApplyResult) => { const statusClass = it.status === "error" ? "is-danger" : it.status === "created" ? "is-create" : (it.status || "").startsWith("skipped") ? "is-skip" : ""; const statusLabel = { "created": isApplyResult ? "Creado" : "Crear", "skipped_already_in_branch": "Ya en destino", "skipped_in_other_branch": "En otra sucursal", "skipped_no_tienda": "Sin TIENDA", "skipped_unknown_tienda": "TIENDA desconocida", "error": "Error", "pending": "Pendiente", }[it.status] || it.status; const dataParts = [it.phone, it.email].filter(Boolean).join(" / "); const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const actionsHTML = (it.actions || []).map(a => { if (a.action === "skip_no_tienda") return `sin TIENDA`; if (a.action === "skip_unknown_tienda") return `TIENDA '${escapeHtml(a.tienda || "")}' desconocida`; if (a.action === "skip_already_in_branch") return `ya existe en destino (por ${escapeHtml(a.strategy)})`; if (a.action === "skip_in_other_branch") return `ya en otra: ${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')} (por ${escapeHtml(a.strategy)})`; if (a.action === "create_contact_in_branch") return `crear en sucursal`; return `${escapeHtml(a.action || "")}`; }).join(" "); return ` ${statusLabel} ${escapeHtml(it.name || "(sin nombre)")}
    ${escapeHtml(it.brand_contact_id || "")} ${escapeHtml(it.tienda || "—")} ${escapeHtml(it.target_branch_name || "—")} ${escapeHtml(dataParts || "—")} ${actionsHTML}${errorHTML} `; }, }, // Mismo endpoint y mismo render que brand_not_in_any_branch (los items son // un subset: contactos con TIENDA resuelta y ausentes globalmente). La // diferencia es que aqui SIEMPRE se abre con un subset de contact_ids // seleccionados por el usuario en la tabla del bucket. "missing_in_assigned_branch": { triggerBtnId: "btn-sync-missing-in-assigned-branch", endpoint: "/api/comparativa/sync-brand-to-branch-contacts", modalTitleIcon: "fa-user-plus", bucketTitle: "crear seleccionados en sucursal asignada", applyNoteHtml: "Creando los contactos seleccionados en su sucursal asignada. Antes de crear se verifica en la sucursal destino Y globalmente en todas las demás.", emptyMsg: "No hay contactos seleccionados. Marca los contactos en el listado primero.", summaryFields: (s, isApplyResult) => [ { label: "Candidatos", value: s.candidates }, { label: isApplyResult ? "Creados en sucursal" : "A crear en sucursal", value: s.contacts_created }, { label: "Ya existían en destino", value: s.skipped_already_in_branch || 0 }, { label: "Ya existían en OTRA sucursal", value: s.skipped_in_other_branch || 0 }, { label: "Skip · sin TIENDA", value: s.skipped_no_tienda || 0 }, { label: "Skip · TIENDA desconocida", value: s.skipped_unknown_tienda || 0 }, { label: "Errores", value: s.errors, danger: !!s.errors }, ], columns: ["Acción", "Contacto Marca", "TIENDA", "Sucursal destino", "Datos", "Operaciones"], renderRow: (it, isApplyResult) => { const statusClass = it.status === "error" ? "is-danger" : it.status === "created" ? "is-create" : (it.status || "").startsWith("skipped") ? "is-skip" : ""; const statusLabel = { "created": isApplyResult ? "Creado" : "Crear", "skipped_already_in_branch": "Ya en destino", "skipped_in_other_branch": "En otra sucursal", "skipped_no_tienda": "Sin TIENDA", "skipped_unknown_tienda": "TIENDA desconocida", "error": "Error", "pending": "Pendiente", }[it.status] || it.status; const dataParts = [it.phone, it.email].filter(Boolean).join(" / "); const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const actionsHTML = (it.actions || []).map(a => { if (a.action === "skip_no_tienda") return `sin TIENDA`; if (a.action === "skip_unknown_tienda") return `TIENDA '${escapeHtml(a.tienda || "")}' desconocida`; if (a.action === "skip_already_in_branch") return `ya existe en destino (por ${escapeHtml(a.strategy)})`; if (a.action === "skip_in_other_branch") return `ya en otra: ${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')} (por ${escapeHtml(a.strategy)})`; if (a.action === "create_contact_in_branch") return `crear en sucursal`; return `${escapeHtml(a.action || "")}`; }).join(" "); return ` ${statusLabel} ${escapeHtml(it.name || "(sin nombre)")}
    ${escapeHtml(it.brand_contact_id || "")} ${escapeHtml(it.tienda || "—")} ${escapeHtml(it.target_branch_name || "—")} ${escapeHtml(dataParts || "—")} ${actionsHTML}${errorHTML} `; }, }, "contacts_missing_id_field": { triggerBtnId: "btn-fill-contact-id-sucursal", endpoint: "/api/comparativa/fill-contact-id-sucursal", // bodyIdsField default "contact_ids" — coincide con el endpoint modalTitleIcon: "fa-wand-magic-sparkles", bucketTitle: "llenar ID Contacto Sucursal en sucursales", applyNoteHtml: "Escribiendo el campo en GHL. Cada contacto queda con su propio id nativo (idempotente). No toca Marca.", emptyMsg: "No hay contactos de sucursal con el campo vacío o longitud inválida. ¡Todo en orden!", summaryFields: (s, isApplyResult) => [ { label: "Cuentas en scope", value: s.accounts_processed || 0 }, { label: "Contactos revisados", value: s.contacts_reviewed || 0 }, { label: isApplyResult ? "Campos llenados" : "A llenar", value: s.set || 0 }, { label: "Ya OK (idempotente)", value: s.skipped || 0 }, { label: "Errores", value: s.errors || 0, danger: !!s.errors }, ], columns: ["Cuenta", "Contactos revisados", "Llenados", "Ya OK", "Errores"], renderRow: (it, isApplyResult) => { const statusClass = (it.status === "apply_error" || it.status === "plan_error" || (it.errors || 0) > 0) ? "is-danger" : ((it.set || 0) > 0 ? "is-update" : "is-skip"); const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const accountCell = `${escapeHtml(it.account || it.location_id || "")}
    ${escapeHtml(it.location_id || "")}`; return ` ${accountCell} ${escapeHtml(String(it.contacts_in_scope || 0))} ${escapeHtml(String(it.set || 0))} ${escapeHtml(String(it.skipped || 0))} ${escapeHtml(String(it.errors || 0))}${errorHTML} `; }, }, "opps_missing_id_field": { triggerBtnId: "btn-fill-opp-id-sucursal", endpoint: "/api/comparativa/fill-opp-id-sucursal", bodyIdsField: "opp_ids", modalTitleIcon: "fa-wand-magic-sparkles", bucketTitle: "llenar ID Oportunidad Sucursal en sucursales", applyNoteHtml: "Escribiendo el campo en GHL. Cada opp queda con su propio id nativo (idempotente). No toca Marca.", emptyMsg: "No hay opps de sucursal con el campo vacío o longitud inválida. ¡Todo en orden!", summaryFields: (s, isApplyResult) => [ { label: "Cuentas en scope", value: s.accounts_processed || 0 }, { label: "Opps revisadas", value: s.opps_reviewed || 0 }, { label: isApplyResult ? "Campos llenados" : "A llenar", value: s.set || 0 }, { label: "Ya OK (idempotente)", value: s.skipped || 0 }, { label: "Errores", value: s.errors || 0, danger: !!s.errors }, ], columns: ["Cuenta", "Opps revisadas", "Llenados", "Ya OK", "Errores"], renderRow: (it, isApplyResult) => { const statusClass = (it.status === "apply_error" || it.status === "plan_error" || (it.errors || 0) > 0) ? "is-danger" : ((it.set || 0) > 0 ? "is-update" : "is-skip"); const errorHTML = it.error ? `
    ${escapeHtml(it.error)}
    ` : ""; const accountCell = `${escapeHtml(it.account || it.location_id || "")}
    ${escapeHtml(it.location_id || "")}`; return ` ${accountCell} ${escapeHtml(String(it.opps_in_scope || 0))} ${escapeHtml(String(it.set || 0))} ${escapeHtml(String(it.skipped || 0))} ${escapeHtml(String(it.errors || 0))}${errorHTML} `; }, }, "opps_missing_id_field_brand": { triggerBtnId: "btn-match-brand-opp-id-sucursal", endpoint: "/api/comparativa/match-brand-opp-id-sucursal", bodyIdsField: "opp_ids", modalTitleIcon: "fa-arrows-left-right-to-line", bucketTitle: "matchear opps de Marca con sucursales", applyNoteHtml: "Escribiendo el campo 'ID Oportunidad Sucursal' en Marca con el id de la opp sucursal correspondiente. Solo se aplican los matches inequívocos (1-a-1). No toca sucursales.", emptyMsg: "No hay matches inequívocos para Marca. Las opps en review/no-match requieren inspección manual.", hasWorkToDo: (s) => (s.match_unique || 0) > 0, summaryFields: (s, isApplyResult) => [ { label: "Opps Marca evaluadas", value: s.plans_total || 0 }, { label: isApplyResult ? "Matches aplicados" : "Match único (a aplicar)", value: isApplyResult ? (s.applied || 0) : (s.match_unique || 0) }, { label: "Revisión (ambiguos / count)", value: (s.review_ambiguous || 0) + (s.review_count_mismatch || 0) }, { label: "Sin opps en sucursal", value: s.branch_contact_no_opps || 0 }, { label: "Sin contacto sucursal", value: (s.no_branch_contact || 0) + (s.phone_collision || 0) + (s.no_data || 0) + (s.no_brand_contact || 0) }, { label: "Errores", value: s.errors || 0, danger: !!s.errors }, ], columns: ["Opp Marca", "Estado", "Motivo / branch opp", "Sucursal"], renderRow: (it, isApplyResult) => { const plan = it.plan || {}; const st = plan.status || "—"; const isMatch = st === "match_unique"; const cls = isMatch ? "is-update" : (st.startsWith("review_") ? "is-skip" : "is-danger"); const oppName = it.marca_opp_name || "(sin nombre)"; const detail = isMatch ? `${escapeHtml(plan.branch_opp_id || "")} (${escapeHtml(plan.matched_by || "")})` : (st === "review_count_mismatch" || st === "review_ambiguous") ? `M=${plan.M ?? "?"} / N=${plan.N ?? "?"}` : `${escapeHtml(st)}`; const loc = plan.branch_location ? `${escapeHtml(plan.branch_location)}` : "—"; return ` ${escapeHtml(it.marca_opp_id || "")}
    ${escapeHtml(oppName)} ${escapeHtml(st)} ${detail} ${loc} `; }, }, "brand_duplicate_link_opps": { triggerBtnId: "btn-cleanup-duplicate-opps", endpoint: "/api/comparativa/cleanup-duplicate-opps", modalTitleIcon: "fa-broom", bucketTitle: "réplicas duplicadas en Marca", applyNoteHtml: "Eliminando en GHL las réplicas sobrantes (conserva la canónica). El DELETE no es reversible en GHL, pero queda snapshot + run_id para auditoría/recreación.", emptyMsg: "No hay réplicas duplicadas en Marca. ¡Todo limpio!", hasWorkToDo: (s) => (s.extra_opps || 0) > 0, summaryFields: (s, isApplyResult) => [ { label: "Clusters (mismo ID Opp Sucursal)", value: s.clusters || 0 }, { label: isApplyResult ? "Réplicas borradas" : "Réplicas a borrar", value: isApplyResult ? (s.deleted || 0) : (s.extra_opps || 0) }, { label: "Clusters resueltos en vivo", value: s.ok || 0 }, { label: "Omitidos (ya sin duplicado)", value: s.skipped || 0 }, { label: "Errores", value: s.errors || 0, danger: !!s.errors }, ], columns: ["Acción", "Oportunidad", "Conservar", "Borrar", "ID Opp Sucursal"], renderRow: (it, isApplyResult) => { const k = it.keep || {}; const dels = it.delete || []; const statusClass = isApplyResult ? "is-danger" : "is-skip"; const statusLabel = isApplyResult ? "Borrada" : "Borrar"; const keepHTML = `${escapeHtml(k.id || "")}
    ${escapeHtml(k.status || "")} · $${Number(k.monetaryValue || 0).toLocaleString("es-MX")} · ${escapeHtml((k.createdAt || "").slice(0, 10))}`; const delHTML = dels.map(d => `${escapeHtml(d.id || "")} (${escapeHtml((d.createdAt || "").slice(0, 10))})`).join("
    "); return ` ${statusLabel} ${escapeHtml(it.name || "(sin nombre)")} ${keepHTML} ${delHTML} ${escapeHtml(it.link_value || "")} `; }, }, }; let syncBucketState = { bound: false, activeBucket: null, planResult: null, loading: false, contactIds: null, // null = todos los del bucket; array = subset seleccionado por el usuario. }; function bindSyncBucketEvents() { if (syncBucketState.bound) return; Object.entries(SYNC_BUCKETS_CONFIG).forEach(([bucketKey, cfg]) => { const btn = document.getElementById(cfg.triggerBtnId); if (!btn) return; btn.addEventListener("click", () => { // Buckets con seleccion en bulk: pasamos los IDs marcados. if (bucketKey === "missing_in_assigned_branch") { const sel = (typeof getBulkSelectionSet === "function") ? getBulkSelectionSet("missing_in_assigned_branch") : new Set(); if (!sel.size) { if (typeof showToast === "function") { showToast("Selecciona al menos un contacto del listado para crear en sucursal.", "info"); } return; } openSyncBucketModal(bucketKey, { contactIds: Array.from(sel) }); return; } // contacts_missing_id_field: el llenado SOLO aplica a sucursales // (Marca se resuelve por matcheo/sync). Filtramos los items. if (bucketKey === "contacts_missing_id_field") { const items = (comparativaState && comparativaState.data && comparativaState.data.missing && comparativaState.data.missing.contacts_missing_id_field && comparativaState.data.missing.contacts_missing_id_field.items) || []; const branchContactIds = items.filter(it => !it.is_brand).map(it => it.id).filter(Boolean); if (!branchContactIds.length) { if (typeof showToast === "function") { showToast("No hay contactos de sucursal con el campo vacío o inválido.", "info"); } return; } openSyncBucketModal(bucketKey, { contactIds: branchContactIds }); return; } // opps_missing_id_field: el llenado SOLO aplica a sucursales (Marca // se resuelve por matcheo/sync). Filtramos del bucket actual. if (bucketKey === "opps_missing_id_field") { const items = (comparativaState && comparativaState.data && comparativaState.data.missing && comparativaState.data.missing.opportunities_missing_id_field && comparativaState.data.missing.opportunities_missing_id_field.items) || []; const branchOppIds = items.filter(it => !it.is_brand).map(it => it.id).filter(Boolean); if (!branchOppIds.length) { if (typeof showToast === "function") { showToast("No hay opps de sucursal con el campo vacío o inválido. (Las de Marca se resuelven por matcheo/sync.)", "info"); } return; } openSyncBucketModal(bucketKey, { contactIds: branchOppIds }); return; } // opps_missing_id_field_brand: bucket sintético que apunta al endpoint // de matching Marca↔sucursal. Filtra las filas Marca del bucket original. if (bucketKey === "opps_missing_id_field_brand") { const items = (comparativaState && comparativaState.data && comparativaState.data.missing && comparativaState.data.missing.opportunities_missing_id_field && comparativaState.data.missing.opportunities_missing_id_field.items) || []; const brandOppIds = items.filter(it => it.is_brand).map(it => it.id).filter(Boolean); if (!brandOppIds.length) { if (typeof showToast === "function") { showToast("No hay opps de Marca con el campo vacío o inválido para matchear.", "info"); } return; } openSyncBucketModal(bucketKey, { contactIds: brandOppIds }); return; } openSyncBucketModal(bucketKey); }); }); const btnClose = document.getElementById("sync-modal-close"); const btnCancel = document.getElementById("sync-modal-cancel"); const btnApply = document.getElementById("sync-modal-apply"); const overlay = document.getElementById("sync-missing-opps-modal"); if (btnClose) btnClose.addEventListener("click", closeSyncBucketModal); if (btnCancel) btnCancel.addEventListener("click", closeSyncBucketModal); if (btnApply) btnApply.addEventListener("click", applySyncBucket); if (overlay) { overlay.addEventListener("click", (e) => { if (e.target === overlay) closeSyncBucketModal(); }); } syncBucketState.bound = true; } function openSyncBucketModal(bucketKey, opts) { bindSyncBucketEvents(); syncBucketState.activeBucket = bucketKey; syncBucketState.contactIds = (opts && Array.isArray(opts.contactIds) && opts.contactIds.length) ? opts.contactIds.slice() : null; const modal = document.getElementById("sync-missing-opps-modal"); if (!modal) return; modal.hidden = false; modal.classList.add("is-open"); runSyncBucketDryRun(); } function closeSyncBucketModal() { const modal = document.getElementById("sync-missing-opps-modal"); if (!modal) return; modal.hidden = true; modal.classList.remove("is-open"); syncBucketState.planResult = null; syncBucketState.activeBucket = null; syncBucketState.contactIds = null; const apply = document.getElementById("sync-modal-apply"); if (apply) apply.hidden = true; // Restaurar texto del boton por si la proxima apertura arranca en modo plan. const cancel = document.getElementById("sync-modal-cancel"); if (cancel) cancel.innerHTML = "Cancelar"; } async function runSyncBucketDryRun() { const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket]; if (!cfg) return; const body = document.getElementById("sync-modal-body"); const apply = document.getElementById("sync-modal-apply"); if (body) body.innerHTML = `

    Calculando plan... esto consulta el audit completo, puede tomar unos segundos.

    `; if (apply) apply.hidden = true; syncBucketState.loading = true; try { // Preview/plan SIEMPRE es dry_run=true (sin afectar al toggle global). const bodyPayload = { dry_run: true }; const idsField = cfg.bodyIdsField || "contact_ids"; if (Array.isArray(syncBucketState.contactIds) && syncBucketState.contactIds.length) { bodyPayload[idsField] = syncBucketState.contactIds; } const res = await mutateFetch(cfg.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bodyPayload), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); syncBucketState.planResult = data; renderSyncBucketPlan(data, /*isApplyResult*/ false); // Solo mostramos Aplicar si hay candidatos Y no son todos skips (skips no requieren apply). // El bucket puede definir su propia función hasWorkToDo(summary); si no, usa el default. const summary = data.summary || {}; const hasWorkToDo = typeof cfg.hasWorkToDo === "function" ? cfg.hasWorkToDo(summary) : (summary.candidates > 0 && ((summary.contacts_created || 0) > 0 || (summary.contacts_updated || 0) > 0 || (summary.opps_created || 0) > 0 || (summary.opps_updated || 0) > 0)); if (apply && hasWorkToDo) apply.hidden = false; } catch (e) { if (body) body.innerHTML = `

    Error: ${escapeHtml(e.message)}

    `; } finally { syncBucketState.loading = false; } } async function applySyncBucket() { const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket]; if (!cfg || syncBucketState.loading) return; const apply = document.getElementById("sync-modal-apply"); const cancel = document.getElementById("sync-modal-cancel"); const body = document.getElementById("sync-modal-body"); if (apply) { apply.disabled = true; apply.innerHTML = ` Aplicando en GHL...`; } if (cancel) cancel.disabled = true; if (body) { const note = document.createElement("p"); note.className = "text-secondary"; note.style.marginTop = "12px"; note.innerHTML = ` ${cfg.applyNoteHtml}`; body.prepend(note); } syncBucketState.loading = true; try { // El "aplicar" respeta el modo global: en dry-run, fuerza dry_run=true // en el body para que el endpoint legacy no escriba en Bucéfalo. El // header X-Dry-Run también va, por si el endpoint usa el helper nuevo. const respectGlobalMode = window.AppMode.isDryRun; const bodyPayload = { dry_run: respectGlobalMode, yes: true }; const idsField = cfg.bodyIdsField || "contact_ids"; if (Array.isArray(syncBucketState.contactIds) && syncBucketState.contactIds.length) { bodyPayload[idsField] = syncBucketState.contactIds; } const res = await mutateFetch(cfg.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bodyPayload), }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || `HTTP ${res.status}`); } const data = await res.json(); renderSyncBucketPlan(data, /*isApplyResult*/ true); if (apply) apply.hidden = true; // Tras un apply exitoso de un bucket con seleccion en bulk, limpia el // set guardado en memoria para que el siguiente render del bucket // arranque desde cero (los IDs procesados ya no son candidatos). const bucketKey = syncBucketState.activeBucket; if (bucketKey === "missing_in_assigned_branch" && typeof clearBulkSelection === "function") { clearBulkSelection(bucketKey); } comparativaState.data = null; loadComparativaMarcaSucursales(true); } catch (e) { if (body) { const errEl = document.createElement("p"); errEl.className = "text-danger"; errEl.innerHTML = ` Error: ${escapeHtml(e.message)}`; body.prepend(errEl); } } finally { syncBucketState.loading = false; if (apply) { apply.disabled = false; apply.innerHTML = ` Aplicar cambios en GHL`; } if (cancel) cancel.disabled = false; } } function renderSyncBucketPlan(data, isApplyResult) { const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket]; if (!cfg) return; const body = document.getElementById("sync-modal-body"); const title = document.getElementById("sync-modal-title"); if (!body) return; const s = data.summary || {}; const items = data.items || []; if (title) { title.innerHTML = isApplyResult ? ` Resultado de la sincronización · ${cfg.bucketTitle}` : ` Plan de sincronización · ${cfg.bucketTitle}`; } // Tras aplicar los cambios ya no tiene sentido "Cancelar" (los cambios // estan en GHL y el rollback se hace por run_id desde Scripts y Auditorias). // Cambiamos el texto a "Cerrar" para que el boton refleje lo que realmente hace. const cancelBtn = document.getElementById("sync-modal-cancel"); if (cancelBtn) { cancelBtn.innerHTML = isApplyResult ? ` Cerrar` : `Cancelar`; } if (!s.candidates) { body.innerHTML = `

    ${cfg.emptyMsg}

    `; return; } const fields = cfg.summaryFields(s, isApplyResult); const summaryHTML = `
    ${fields.map(f => `
    ${escapeHtml(f.label)}${f.value}
    `).join("")}
    ${data.run_id ? `

    run_id: ${escapeHtml(data.run_id)} (disponible para rollback desde Scripts y Auditorías)

    ` : ""} ${isApplyResult ? "" : "

    Revisa el detalle a continuación. Si todo OK, haz click en Aplicar cambios en GHL.

    "} `; const detailRows = items.map(it => cfg.renderRow(it, isApplyResult)).join(""); body.innerHTML = ` ${summaryHTML}
    ${cfg.columns.map(c => ``).join("")}${detailRows}
    ${escapeHtml(c)}
    `; } document.addEventListener("DOMContentLoaded", () => { bindSyncBucketEvents(); });