Files
MP-Manager/static/js/app.js
T
2026-05-30 14:31:19 -06:00

6860 lines
323 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================================================
// 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 <strong>escribirán en Bucéfalo de verdad</strong> (eliminar
contactos, crear oportunidades, sincronizar Marca↔Sucursal, etc.).<br><br>
Si tu intención es probar primero, mantén el modo en <strong>Simulación</strong>.
`,
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 = '<i class="fa-solid fa-terminal"></i><p>Terminal Limpia</p><span>Selecciona un script y presiona ejecutar para ver resultados aquí.</span>';
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", `<i class="fa-solid fa-play"></i> 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 = `
<i class="fa-solid fa-${icon}"></i>
<span>${message}</span>
`;
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 = `
<div class="modal-card severity-${severity}">
<div class="modal-header">
<h3><i class="fa-solid fa-${icon}"></i> ${title}</h3>
<button class="btn-close-modal" data-act="cancel" type="button">&times;</button>
</div>
<div class="modal-body">${bodyHTML}</div>
<div class="modal-footer">${footerHTML}</div>
</div>
`;
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: `<p class="app-modal-message">${message}</p>`,
footerHTML: `
<button class="btn btn-secondary" data-act="cancel" type="button">${cancelText}</button>
<button class="btn ${confirmBtnClass}" data-act="ok" type="button">${confirmText}</button>
`,
});
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) => `
<label class="app-modal-radio-row">
<input type="radio" name="app-modal-select" value="${i}" ${i === 0 ? 'checked' : ''}>
<div class="app-modal-radio-text">
<div class="app-modal-radio-label">${opt.label}</div>
${opt.sublabel ? `<div class="app-modal-radio-sublabel">${opt.sublabel}</div>` : ''}
</div>
</label>
`).join('');
const modal = _createAppModal({
title, severity,
bodyHTML: `
<p class="app-modal-message">${message}</p>
<div class="app-modal-radio-list">${optionsHTML}</div>
`,
footerHTML: `
<button class="btn btn-secondary" data-act="cancel" type="button">${cancelText}</button>
<button class="btn btn-primary" data-act="ok" type="button">${confirmText}</button>
`,
});
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: `
<p class="app-modal-message">${message}</p>
<input type="text" class="app-modal-prompt-input" placeholder="${placeholder}" autocomplete="off" spellcheck="false">
`,
footerHTML: `
<button class="btn btn-secondary" data-act="cancel" type="button">Cancelar</button>
<button class="btn btn-danger" data-act="ok" type="button" disabled>Confirmar</button>
`,
});
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: `
<div class="app-progress-body">
<div class="app-progress-icon spinning" data-icon></div>
<div class="app-progress-title" data-title>${initialDetail || 'Iniciando…'}</div>
<div class="app-progress-detail" data-detail></div>
<div class="app-progress-log" data-log></div>
</div>
`,
footerHTML: `<button class="btn btn-secondary" data-act="close" type="button" style="display:none;">Cerrar</button>`,
});
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 = '<i class="fa-solid fa-check"></i>';
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 = '<i class="fa-solid fa-xmark"></i>';
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 = `
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fa-solid fa-earth-americas" style="font-size: 16px; color: ${activeBranchId === "global" ? "#fff" : "var(--color-primary)"};"></i>
<div>
<div class="branch-item-name" style="font-weight: 700;">Consolidado Global</div>
<div class="branch-item-meta">Todas las sucursales</div>
</div>
</div>
<div class="branch-item-meta text-right">
<div><i class="fa-solid fa-user"></i> ${totalContacts}</div>
<div><i class="fa-solid fa-briefcase"></i> ${totalOpps}</div>
</div>
`;
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 = `
<div>
<div class="branch-item-name">${displayName}</div>
<div class="branch-item-meta">${b.location_id.substring(0, 8)}...</div>
</div>
<div class="branch-item-meta text-right">
<div><i class="fa-solid fa-user"></i> ${contactsCount}</div>
<div><i class="fa-solid fa-briefcase"></i> ${oppsCount}</div>
</div>
`;
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 = `
<i class="fa-solid fa-earth-americas"></i> Resumen de las 49 sucursales | <code>Vista Consolidada</code>
`;
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 =
`<a href="${ghlUrl}" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir sucursal en Bucéfalo">${b.nombre}</a>`;
document.getElementById("active-branch-owner").innerHTML = `
<i class="fa-solid fa-building"></i> ${b.company_owner} | Location: <code>${b.location_id}</code>
`;
}
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 = `
<div class="dist-bar-label">
<span>${s.label}</span>
<span>${s.count} (${pct}%)</span>
</div>
<div class="dist-bar-bg">
<div class="dist-bar-fill ${s.class}" style="width: ${pct}%"></div>
</div>
`;
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 = `
<span><i class="fa-solid fa-store"></i> ${branches.length} sucursales</span>
<span><i class="fa-solid fa-user-group"></i> ${totalContacts} contactos</span>
<span><i class="fa-solid fa-briefcase"></i> ${totalOpps} oportunidades</span>
<span><i class="fa-solid fa-diagram-project"></i> ${totalPipelines} pipelines</span>
`;
return;
}
const branch = branchesData.find(x => x.location_id === activeBranchId);
if (!branch) {
details.innerHTML = `<span>Cuenta no encontrada</span>`;
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 = `
<span><i class="fa-solid fa-user-group"></i> ${metrics.contacts_count || 0} contactos</span>
<span><i class="fa-solid fa-briefcase"></i> ${metrics.opps_count || 0} oportunidades</span>
<span><i class="fa-solid fa-diagram-project"></i> ${pipelineCount} pipelines</span>
<span><i class="fa-solid fa-list-check"></i> ${stagesLabel}</span>
`;
}
async function loadPipelineOverview() {
const container = document.getElementById("pipeline-overview-container");
if (!container) return;
if (activeBranchId === "global") {
container.innerHTML = `<p class="text-secondary">El overview de pipelines se muestra al seleccionar una sucursal o cuenta específica.</p>`;
return;
}
const requestedLocationId = activeBranchId;
container.innerHTML = `<p class="text-secondary"><i class="fa-solid fa-spinner fa-spin"></i> Cargando pipelines y etapas...</p>`;
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 = `<p class="text-secondary">No hay pipelines registrados para esta cuenta. Sincroniza la cuenta para actualizar el cache local.</p>`;
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 `
<div class="pipeline-stage-row">
<span class="stage-order">${index + 1}</span>
<span class="stage-name">${stage.name || "Etapa sin nombre"}</span>
<span class="stage-opp-count" title="Oportunidades en esta etapa">${stageOppCount}</span>
</div>
`;
}).join("")
: `<span class="text-secondary">Sin etapas registradas.</span>`;
return `
<div class="pipeline-overview-item">
<div class="pipeline-overview-item-header">
<div>
<span class="pipeline-label">Pipeline</span>
<strong><a href="https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(requestedLocationId)}/opportunities/pipeline/${encodeURIComponent(pipe.id || "")}?tab=stages" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir pipeline ${pipe.name || ""} en Bucéfalo">${pipe.name || "Pipeline sin nombre"}</a> <code class="pipeline-id" title="ID del pipeline">${pipe.id || "—"}</code></strong>
</div>
<span title="Oportunidades en este pipeline / etapas configuradas"><i class="fa-solid fa-briefcase" style="margin-right: 4px;"></i>${pipeOppCount} opps · ${stages.length} etapas</span>
</div>
<div class="pipeline-stages-title">Etapas</div>
<div class="pipeline-stages-list">${stagesHtml}</div>
</div>
`;
}).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
? `<div class="pipeline-overview-actions">
<button id="btn-dedupe-pipelines" class="btn btn-primary btn-sm"
data-location-id="${requestedLocationId}"
title="Migra todas las oportunidades del pipeline más antiguo al más reciente (por dateUpdated). Mapea etapas por nombre cuando coinciden o por posición. El plan se previsualiza antes de aplicar.">
<i class="fa-solid fa-shuffle"></i> Migrar al pipeline más reciente
</button>
</div>`
: "";
container.innerHTML = `
<div class="pipeline-overview-summary">
<div><strong>${pipelines.length}</strong><span>pipelines disponibles</span></div>
<div><strong>${totalOpps}</strong><span>oportunidades totales</span></div>
<div><strong>${stagesTotal}</strong><span>etapas configuradas</span></div>
</div>
${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 = `<p class="text-danger">No se pudo cargar el overview de pipelines.</p>`;
}
}
/**
* 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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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 <code>--from-pipeline</code> y <code>--to-pipeline</code>.",
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 =>
`<li><code>${escapeHtmlAttr(m.from_stage)}</code> → <code>${escapeHtmlAttr(m.to_stage)}</code></li>`
).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)
? `
<div style="margin-top: 8px; padding: 8px 10px; background: rgba(255, 159, 64, 0.06); border: 1px solid rgba(255, 159, 64, 0.2); border-radius: 6px;">
<div style="font-weight: 600; color: #ffb05a; margin-bottom: 4px;">Duplicados detectados — resolución automática</div>
<div style="font-size: 11px; line-height: 1.5;">
<div>• <strong>${plain}</strong> opps se moverían directamente (sin duplicado).</div>
<div>• <strong>${mergeObs}</strong> opps el <strong>obsoleto gana</strong> (etapa más avanzada): sus custom fields se copian a la oficial, la oficial se actualiza al stage avanzado, y la obsoleta se elimina.</div>
<div>• <strong>${mergeOff}</strong> opps la <strong>oficial gana</strong> (ya estaba más avanzada): la oficial queda intacta, la obsoleta simplemente se elimina.</div>
</div>
</div>
`
: "";
return `
<div style="margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--border-color, rgba(255,255,255,0.08)); border-radius: 8px;">
<div style="font-weight: 600; margin-bottom: 6px;">Plan ${idx + 1}</div>
<div style="font-size: 12px; color: var(--text-secondary);">
<div><strong>Origen (obsoleto):</strong> ${escapeHtmlAttr(p.obsolete.name)} <code style="font-size:10px;">${escapeHtmlAttr(p.obsolete.id)}</code></div>
<div style="opacity: 0.7;">actualizado ${fmtDate(p.obsolete.date_updated)}</div>
<div style="margin-top: 6px;"><strong>Destino (oficial):</strong> ${escapeHtmlAttr(p.official.name)} <code style="font-size:10px;">${escapeHtmlAttr(p.official.id)}</code></div>
<div style="opacity: 0.7;">actualizado ${fmtDate(p.official.date_updated)}</div>
<div style="margin-top: 6px;"><strong>Mapeo de etapas:</strong> ${escapeHtmlAttr(p.mapping_kind || "n/a")}</div>
${mappingHtml ? `<ul style="margin: 4px 0 4px 18px; font-size: 11px;">${mappingHtml}</ul>` : ""}
<div style="margin-top: 6px;"><strong>Oportunidades a procesar:</strong> ${p.opportunities_count}</div>
${breakdownHtml}
</div>
</div>
`;
}).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: `
<p style="margin-bottom: 10px;">${escapeHtmlAttr(preview.message || "")}</p>
<div style="max-height: 320px; overflow-y: auto;">${plansHTML}</div>
${isLive
? `<div class="app-modal-meta"><strong>Atención:</strong> 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.</div>`
: `<div class="app-modal-meta">Estás en modo Simulación. Confirmar solo cierra este preview — nada se aplicará. Cambia a Live desde el toggle del header para aplicar.</div>`
}
`,
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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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 = `<tr><td colspan="5" class="text-center">No se encontraron sincronizaciones previas.</td></tr>`;
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 = `
<td><strong>${log.name.replace(/^\d+\s*-\s*MP\s*-\s*/, "")}</strong></td>
<td>${log.started_at.replace("T", " ").substring(0, 19)}</td>
<td><span class="badge badge-${statusClass}">${statusLabel}</span></td>
<td>${log.contacts_synced}</td>
<td>${log.opps_synced}</td>
`;
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
? `<i class="fa-solid fa-user-slash"></i> Filtrando: sin oportunidad <i class="fa-solid fa-xmark" style="margin-left: 6px;"></i>`
: `<i class="fa-solid fa-user-slash"></i> 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 = `<i class="fa-solid fa-layer-group"></i> 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("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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 = `
<tr>
<td colspan="6" class="text-center" style="padding: 40px;">
<div style="font-size: 32px; color: var(--text-muted); margin-bottom: 12px;"><i class="fa-solid fa-users-viewfinder"></i></div>
<h4 style="color: var(--text-primary); margin-bottom: 8px;">Visualización de Contactos por Sucursal</h4>
<p style="color: var(--text-secondary); max-width: 500px; margin: 0 auto; font-size: 13px;">
Para ver y filtrar contactos locales, selecciona una sucursal específica de Monte Providencia en la barra lateral izquierda.
</p>
</td>
</tr>
`;
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 = `<tr><td colspan="7" class="text-center"><i class="fa-solid fa-spinner fa-spin"></i> ${loadingMsg}</td></tr>`;
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 = `<tr><td colspan="${colspan}" class="text-center">${emptyMsg}</td></tr>`;
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 => `<span class="tag-badge">${escapeHtmlAttr(t)}</span>`).join("");
const tagsRemaining = tags.length > 3 ? `<span class="tag-badge">+${tags.length - 3}</span>` : "";
// 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
? ` <span class="tag-badge" style="background: rgba(255, 159, 64, 0.2); color: #ff9f40; border: 1px solid rgba(255,159,64,0.4); font-size: 10px; margin-left: 6px;" title="${testReasonsAttr}">PRUEBA</span>`
: "";
const nameCellHTML = `<td>
<a href="${crmUrl}" target="_blank" rel="noopener noreferrer" class="contact-name-link" title="Abrir contacto en Bucéfalo">
<strong>${escapeHtmlAttr(displayName)}</strong>
</a>${testBadge}
</td>`;
// 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(`<button class="btn btn-primary btn-sm btn-create-opp"
data-location-id="${activeBranchId}"
data-contact-id="${escapeHtmlAttr(c.id)}"
data-contact-name="${escapeHtmlAttr(displayName || c.email || c.phone || c.id)}"
title="Crea la oportunidad para este contacto en la última etapa del pipeline 'Standar', copiando sucursal/tienda/fuente/canal del contacto.">
<i class="fa-solid fa-plus"></i> Crear oportunidad
</button>`);
}
const deleteTitle = c.is_test
? `Marcado como prueba: ${(c.test_reasons || []).join(" · ") || "—"}`
: "Eliminar contacto (definitivo, hace cascade a oportunidades)";
buttonsHTML.push(`<button class="btn btn-danger btn-sm btn-delete-contact"
data-location-id="${activeBranchId}"
data-contact-id="${escapeHtmlAttr(c.id)}"
data-contact-name="${escapeHtmlAttr(displayName || c.email || c.phone || c.id)}"
data-is-test="${c.is_test ? '1' : '0'}"
data-test-reasons="${testReasonsAttr}"
title="${escapeHtmlAttr(deleteTitle)}">
<i class="fa-solid fa-trash"></i> Eliminar
</button>`);
const actionCellHTML = `<td><div style="display:flex;gap:6px;justify-content:flex-end;">${buttonsHTML.join("")}</div></td>`;
// Fuente nativa de Bucéfalo (source). Si está vacía mostramos placeholder gris.
const sourceHTML = c.source
? `<span class="source-badge">${escapeHtmlAttr(c.source)}</span>`
: '<span class="text-muted">—</span>';
// Info de vehículo (custom fields del contacto). Placeholder gris si falta.
const vehCell = v => v ? escapeHtmlAttr(v) : '<span class="text-muted">—</span>';
const tr = document.createElement("tr");
if (c.is_test) tr.classList.add("contact-row-test");
tr.innerHTML = `
${nameCellHTML}
<td>${c.phone || '<span class="text-muted">N/A</span>'}</td>
<td>${c.email || '<span class="text-muted">N/A</span>'}</td>
<td>${sourceHTML}</td>
<td>${tagsHTML}${tagsRemaining || (tags.length === 0 ? '<span class="text-muted">Ninguna</span>' : '')}</td>
<td>${vehCell(c.marca_vehiculo)}</td>
<td>${vehCell(c.version_vehiculo)}</td>
<td>${vehCell(c.ano_vehiculo)}</td>
<td>${c.date_added ? c.date_added.substring(0, 10) : "N/A"}</td>
${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 = `<tr><td colspan="9" class="text-center text-danger">Error cargando contactos locales. Sincroniza la cuenta si está vacía.</td></tr>`;
}
}
/**
* 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)
? `<div class="app-modal-meta"><strong>Motivos detectados:</strong> ${reasonsAttr}</div>`
: "";
const intro = isTest
? `Vas a eliminar un contacto marcado como <strong>prueba/test</strong>:`
: `Vas a eliminar el contacto:`;
const ok = await appConfirm({
title: "Eliminar contacto",
severity: "danger",
message: `
${intro}<br><br>
<strong>${contactName}</strong><br>
<code style="font-size: 11px; color: var(--text-muted);">${contactId}</code><br><br>
Esta acción borra el contacto en <strong>Bucéfalo</strong> de forma definitiva
(no hay rollback) y elimina sus <strong>oportunidades en cascada</strong>.
${reasonsBlock}
`,
confirmText: "Eliminar definitivamente",
cancelText: "Cancelar",
});
if (!ok) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin"></i> 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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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 <strong>${count} contactos sin oportunidad</strong>
de la sucursal <strong>${escapeHtmlAttr(branchName)}</strong>.<br><br>
Cada oportunidad se crea con:
<ul style="margin: 8px 0 8px 18px;">
<li>Estatus <code>open</code> y pipeline <code>Standar</code> (última etapa).</li>
<li>Custom fields copiados del contacto (sucursal, tienda, fuente, canal).</li>
<li>Si al contacto le faltan los 4 campos requeridos, esa fila se <strong>salta</strong> y se reporta.</li>
</ul>
Todos los cambios quedan registrados bajo un mismo <code>run_id</code> en auditoría.
<br><br>
<strong>Importante:</strong> 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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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
? `<ul style="margin: 6px 0 0 18px; font-size: 12px;">${items.map(d => `<li><strong>${escapeHtmlAttr(d.name || d.contact_id)}</strong>: ${escapeHtmlAttr(d.error || "")}</li>`).join("")}</ul>`
: `<div class="text-muted" style="font-size: 12px;">${emptyMsg}</div>`;
await appConfirm({
title: "Resumen de creación en lote",
severity: t.failed > 0 ? "warning" : "info",
message: `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px;">
<div><strong>Procesados:</strong> ${t.processed}</div>
<div><strong>Creadas:</strong> ${t.created}</div>
<div><strong>Saltadas:</strong> ${t.skipped}</div>
<div><strong>Fallidas:</strong> ${t.failed}</div>
</div>
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 10px;">
Run de auditoría: <code>${runId}</code>
</div>
<details ${t.skipped > 0 ? "open" : ""}>
<summary style="cursor:pointer;"><strong>Saltadas</strong> (${t.skipped})</summary>
${renderList(skippedList, "Sin saltadas.")}
</details>
<details ${t.failed > 0 ? "open" : ""} style="margin-top: 6px;">
<summary style="cursor:pointer;"><strong>Fallidas</strong> (${t.failed})</summary>
${renderList(failedList, "Sin fallos.")}
</details>
`,
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 = `<option value="">-- Selecciona una sucursal --</option>`;
board.innerHTML = `
<div class="board-empty-state">
<i class="fa-solid fa-network-wired"></i>
<p>Flujo Kanban de Oportunidades por Sucursal</p>
<span class="text-secondary">El tablero Kanban visualiza las etapas de venta de una sucursal individual. Selecciona una sucursal en la barra lateral para continuar.</span>
</div>
`;
return;
}
select.innerHTML = `<option value="">Cargando pipelines...</option>`;
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 = `<option value="">Sin pipelines</option>`;
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 = `<option value="">Error cargando pipelines</option>`;
renderEmptyBoard();
}
}
function renderEmptyBoard() {
const board = document.getElementById("kanban-board");
board.innerHTML = `
<div class="board-empty-state">
<i class="fa-solid fa-circle-nodes"></i>
<p>No hay pipelines en esta cuenta.</p>
<span class="text-muted">Prueba sincronizar los datos de GHL o valida que la cuenta tenga pipelines activos.</span>
</div>
`;
}
async function loadKanbanBoard() {
const board = document.getElementById("kanban-board");
board.innerHTML = `<div class="board-empty-state"><i class="fa-solid fa-spinner fa-spin"></i> Cargando tablero Kanban cacheado...</div>`;
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 = `
<div class="kanban-column-header">
<h4 title="${stage.name}">${stage.name}</h4>
<span class="kanban-card-count" id="count-stage-${stage.id}">${stageOpps.length}</span>
</div>
<div class="kanban-cards-wrapper" data-stage-id="${stage.id}">
<!-- Cartas -->
</div>
`;
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
? `<div class="kanban-card-vehicle"><i class="fa-solid fa-car"></i> <span>${escapeHtmlAttr(opp.vehiculo)}</span></div>`
: "";
card.innerHTML = `
<div class="kanban-card-title">${opp.name || "Sin nombre"}</div>
<div class="kanban-card-contact">
<i class="fa-solid fa-user-tag"></i> <span>${clientName || "Sin cliente"}</span>
</div>
${vehiculoHTML}
<div class="kanban-card-footer">
<span class="badge ${statusClass}">${statusLabel}</span>
<span class="kanban-card-value">${formatCurrency(opp.monetary_value)}</span>
</div>
`;
cardWrapper.appendChild(card);
});
}
board.appendChild(col);
});
} catch (e) {
board.innerHTML = `<div class="board-empty-state text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Error renderizando el tablero de oportunidades.</div>`;
}
}
// --- 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 = `<p class="text-center"><i class="fa-solid fa-spinner fa-spin"></i> Cargando catálogo de scripts...</p>`;
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 = `<p class="text-center text-danger">Error cargando el catálogo de scripts locales.</p>`;
}
}
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 = `<p class="text-center text-secondary">${message}</p>`;
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 = `
<div class="script-item-header">
<div>
<h4>${escapeHtml(s.title)}</h4>
<p>${escapeHtml(s.name)}</p>
</div>
<span class="badge ${existsClass}">${existsText}</span>
</div>
<p class="desc">${escapeHtml(s.description)}</p>
${scopeControls}
${customControls}
${optionControls}
<div class="script-item-actions">
${advancedArgs}
<button class="btn btn-secondary btn-sm btn-edit-script-metadata" data-name="${escapeHtml(s.name)}">
<i class="fa-solid fa-tags"></i> Metadata
</button>
<button class="btn btn-danger btn-sm btn-delete-script" data-name="${escapeHtml(s.name)}">
<i class="fa-solid fa-trash-can"></i> Borrar
</button>
<button class="btn btn-primary btn-sm btn-run-script" data-name="${escapeHtml(s.name)}">
<i class="fa-solid fa-play"></i> Ejecutar
</button>
</div>
`;
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 => `
<label class="script-option-toggle" title="${escapeHtml(option.description || "")}">
<input type="checkbox" value="${escapeHtml(option.flag)}">
<span>
<strong>${escapeHtml(option.label)}</strong>
<small>${escapeHtml(option.flag)}</small>
</span>
</label>
`).join("");
return `
<div class="script-options-panel">
<div class="script-panel-label">Opciones</div>
<div class="script-options-grid">${options}</div>
</div>
`;
}
function buildScriptCustomControls(scriptName) {
if (scriptName === "reconcile_and_sync_opportunities.py") {
return `
<div class="script-panel-label" style="margin-top: 12px;">Acciones Permitidas</div>
<div class="script-mode-row" id="reconcile-actions-row" style="margin-bottom: 12px;">
<label class="script-mode-choice reconcile-action-choice active">
<input type="radio" name="reconcile-action-type" value="all" checked style="display:none;">
<span>Sincronización Completa</span>
</label>
<label class="script-mode-choice reconcile-action-choice">
<input type="radio" name="reconcile-action-type" value="updates-only" style="display:none;">
<span>Solo Actualizaciones (PUT)</span>
</label>
</div>
`;
}
if (scriptName === "sync_contacts_branch_to_brand.py") {
return `
<div class="script-options-panel" data-contact-sync-controls="true">
<div class="script-panel-label">Datos de Contactos</div>
<div class="script-options-grid">
<label class="script-option-toggle" title="Procesa solo esta cantidad de contactos por sucursal. Útil para pruebas dry-run.">
<span>
<strong>Límite por sucursal</strong>
<input type="number" class="script-contact-sync-limit" min="1" placeholder="Ej. 20">
</span>
</label>
<label class="script-option-toggle" title="Cantidad máxima de contactos a descargar desde Marca y cada sucursal.">
<span>
<strong>Máximo a descargar</strong>
<input type="number" class="script-contact-sync-max" min="1" placeholder="4000">
</span>
</label>
</div>
<label class="script-option-toggle" title="Opcional. Si se deja vacío, copia todos los campos personalizados que existan por nombre en sucursal y marca.">
<span>
<strong>Campos personalizados específicos</strong>
<input type="text" class="script-contact-sync-fields" placeholder="Sucursal, TIENDA, Canal de Origen">
<small>Vacío = todos los campos personalizados compatibles</small>
</span>
</label>
<div class="script-scope-help">Las sucursales solo se leen. Los POST/PUT se hacen únicamente en la cuenta de Marca.</div>
</div>
`;
}
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 `
<details class="script-advanced-args">
<summary>Avanzado</summary>
<input type="text" class="script-item-args-input" id="args-${script.name.replace(".", "-")}" ${placeholder}>
</details>
`;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getScriptBranchOptions() {
return branchesData
.filter(b => b.type === "branch")
.map(b => {
const displayName = b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, "");
return `
<label class="script-location-option">
<input type="checkbox" value="${escapeHtml(b.location_id)}">
<span>${escapeHtml(displayName)}</span>
</label>
`;
})
.join("");
}
function buildScriptScopeControls(scriptName) {
return `
<div class="script-scope-panel" data-script="${escapeHtml(scriptName)}">
<div class="script-panel-label">Alcance</div>
<div class="script-scope-row">
<label class="script-scope-choice">
<input type="radio" name="scope-${escapeHtml(scriptName)}" value="active" checked>
<span>Sucursal activa</span>
</label>
<label class="script-scope-choice">
<input type="radio" name="scope-${escapeHtml(scriptName)}" value="custom">
<span>Seleccionar sucursales</span>
</label>
<label class="script-scope-choice">
<input type="radio" name="scope-${escapeHtml(scriptName)}" value="location_id">
<span>Location ID</span>
</label>
<label class="script-scope-choice danger">
<input type="radio" name="scope-${escapeHtml(scriptName)}" value="all">
<span>Todas</span>
</label>
</div>
<div class="script-location-id-row hidden">
<input type="text" class="script-location-id-input" placeholder="Pega aquí el Location ID...">
</div>
<div class="script-location-picker hidden">
<input type="text" class="script-location-search" placeholder="Buscar sucursal...">
<div class="script-location-actions">
<button type="button" class="btn btn-secondary btn-sm btn-check-visible">Seleccionar visibles</button>
<button type="button" class="btn btn-secondary btn-sm btn-uncheck-all">Limpiar</button>
</div>
<div class="script-location-list">
${getScriptBranchOptions()}
</div>
</div>
<div class="script-scope-help">Las sucursales seleccionadas se ejecutan en paralelo por defecto.</div>
<div class="script-panel-label script-mode-label">Modo de ejecución</div>
<div class="script-mode-row">
<label class="script-mode-choice">
<input type="radio" name="mode-${escapeHtml(scriptName)}" value="sequential">
<span>Una por una</span>
</label>
<label class="script-mode-choice">
<input type="radio" name="mode-${escapeHtml(scriptName)}" value="parallel" checked>
<span>En paralelo</span>
</label>
</div>
</div>
`;
}
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 = `
<span class="err-script"><i class="fa-solid fa-file-code"></i> ${escapeHtml(entry.scriptName)}</span>
<span class="err-task"><i class="fa-solid fa-fingerprint"></i> ${escapeHtml(entry.taskId)}</span>
<span class="err-time">${escapeHtml(entry.timestamp)}</span>
`;
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 = `
<div class="terminal-placeholder">
<i class="fa-solid fa-shield-halved"></i>
<p>Sin errores registrados</p>
<span>Los errores detectados durante la ejecución aparecerán aquí con su script, Task ID y log.</span>
</div>
`;
}
refreshErrorsBadge();
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 · <span class="truncated">${fmt(stats.truncated)} truncadas</span>`;
} 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", `<i class="fa-solid fa-play"></i> Ejecutar`);
}
window.activeExecutingCard = card;
setTerminalState("running", "Ejecutando");
setCardState(card, "running", `<i class="fa-solid fa-spinner fa-spin"></i> 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", `<i class="fa-solid fa-triangle-exclamation"></i> 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", `<i class="fa-solid fa-check"></i> Completado`);
showToast(`Script ${scriptName} finalizado con éxito.`, "success");
} else {
setTerminalState("failed", "Falló");
setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> 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", `<i class="fa-solid fa-check"></i> Completado`);
showToast(`Script ${scriptName} finalizado con éxito.`, "success");
} else {
setTerminalState("failed", "Falló");
setCardState(card, "failed", `<i class="fa-solid fa-triangle-exclamation"></i> 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", `<i class="fa-solid fa-triangle-exclamation"></i> 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", `<i class="fa-solid fa-triangle-exclamation"></i> 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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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 = `<tr><td colspan="10" class="text-center" style="padding: 24px;">No se encontraron sucursales que coincidan con la búsqueda.</td></tr>`;
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 = `<span class="badge badge-success" title="Todos los contactos tienen al menos una oportunidad."><i class="fa-solid fa-circle-check"></i> 0</span>`;
} else {
const sev = noOppPct >= 20 ? "danger" : "warning";
noOppCellHTML = `<button class="badge badge-${sev} btn-view-no-opp" data-id="${b.location_id}" title="Clic para ver los ${noOppCount} contactos sin oportunidad (${noOppPct}% del total)." style="border: none; cursor: pointer; font-family: inherit;"><i class="fa-solid fa-triangle-exclamation"></i> ${noOppCount} <span style="opacity: 0.75; font-weight: 500;">(${noOppPct}%)</span></button>`;
}
// Formatear estado de sync
let syncStatusHTML = '<span class="badge badge-secondary">N/A</span>';
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 = `<span class="badge badge-success" title="Último éxito: ${m.last_sync.finished_at}"><i class="fa-solid fa-circle-check"></i> Éxito (${date} ${time})</span>`;
} else if (status === "failed") {
syncStatusHTML = `<span class="badge badge-danger" title="Error: ${m.last_sync.error_message || 'Desconocido'}\nFecha: ${m.last_sync.finished_at || m.last_sync.started_at}"><i class="fa-solid fa-circle-xmark"></i> Falló</span>`;
} else if (status === "running") {
syncStatusHTML = `<span class="badge badge-warning"><i class="fa-solid fa-arrows-rotate fa-spin"></i> Sync...</span>`;
}
}
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 = `
<td><strong style="color: var(--text-muted);">${displayNum}</strong></td>
<td>
<div style="font-weight: 600; color: var(--text-primary);"><a href="https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(b.location_id)}/dashboard" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir ${displayName} en Bucéfalo">${displayName}</a></div>
<div style="font-size: 11px; color: var(--text-muted); font-family: var(--font-mono);">${b.location_id.substring(0, 12)}...</div>
</td>
<td><i class="fa-solid fa-user" style="color: var(--color-primary); margin-right: 4px; font-size: 12px;"></i> <strong>${m.contacts_count}</strong></td>
<td>${noOppCellHTML}</td>
<td><i class="fa-solid fa-briefcase" style="color: var(--text-muted); margin-right: 4px; font-size: 12px;"></i> ${m.opps_count}</td>
<td><a href="https://crm.bucefalocrm.io/v2/location/${encodeURIComponent(b.location_id)}/opportunities/pipeline" target="_blank" rel="noopener noreferrer" class="active-branch-link" title="Abrir Pipelines de ${displayName} en Bucéfalo"><i class="fa-solid fa-diagram-project" style="color: var(--color-primary); margin-right: 4px; font-size: 12px;"></i> <strong>${m.pipelines_count || 0}</strong></a></td>
<td><strong style="color: var(--color-success);">${formatCurrency(wonVal)}</strong></td>
<td><strong style="color: var(--color-warning);">${formatCurrency(openVal)}</strong></td>
<td>${syncStatusHTML}</td>
<td style="text-align: center;">
<div style="display: flex; gap: 6px; justify-content: center;">
<button class="btn btn-secondary btn-sm btn-view-branch" data-id="${b.location_id}" title="Ver Dashboard de Sucursal" style="padding: 6px 10px;">
<i class="fa-solid fa-eye"></i> Ver
</button>
<button class="btn btn-primary btn-sm btn-sync-branch-inline" data-id="${b.location_id}" title="Sincronizar Sucursal" style="padding: 6px 10px;">
<i class="fa-solid fa-arrows-rotate"></i>
</button>
</div>
</td>
`;
// 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 = `<i class="fa-solid fa-spinner fa-spin"></i>`;
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 = `
<tr>
<td colspan="10" class="text-center">
<i class="fa-solid fa-spinner fa-spin"></i> Cargando workflows...
</td>
</tr>
`;
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 = `
<tr>
<td colspan="10" class="text-center text-danger">
<i class="fa-solid fa-triangle-exclamation"></i> Error al cargar workflows: ${error.message}
</td>
</tr>
`;
}
}
function populateWorkflowsBranchSelect() {
const select = document.getElementById("workflow-branch-select");
if (!select) return;
select.innerHTML = '<option value="all">-- Todas las sucursales --</option>';
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 = `<option value="${activeBranchId}">${b.nombre}</option>`;
}
}
}
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 = `
<tr>
<td colspan="10" class="text-center text-muted">
No se encontraron workflows con los filtros seleccionados.
</td>
</tr>
`;
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 || '<span class="text-secondary">-</span>';
const dateFormatted = w.created_at ? new Date(w.created_at).toLocaleString('es-ES') : '<span class="text-secondary">-</span>';
const updatedFormatted = w.updated_at ? new Date(w.updated_at).toLocaleString('es-ES') : '<span class="text-secondary">-</span>';
const syncedFormatted = w.synced_at ? new Date(w.synced_at).toLocaleString('es-ES') : '<span class="text-secondary">-</span>';
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 `
<tr>
<td style="text-align:center;">
<input type="checkbox" class="workflow-row-check" data-key="${key}" data-id="${w.id}" data-location="${w.location_id}" data-status="${w.status}" data-name="${(w.name || '').replace(/"/g, '&quot;')}" data-account="${(w.account_name || '').replace(/"/g, '&quot;')}" ${isChecked}>
</td>
<td><a href="https://crm.bucefalocrm.io/v2/location/${w.location_id}/automation/workflows?listTab=all&tab=recent" target="_blank" rel="noopener noreferrer" title="Abrir directorio de workflows en Bucéfalo" style="color: #60A5FA; text-decoration: none; font-weight: bold;">${w.account_name || w.location_id}</a></td>
<td><code>${w.id}</code></td>
<td>${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 ` <span class="badge" style="background: rgba(52, 211, 153, 0.18); color:#34D399; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:bold; margin-left:6px;" title="Sin anomalías detectadas"> limpio</span>`;
return ` <span class="badge" style="background: rgba(245, 158, 11, 0.18); color:#F59E0B; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:bold; margin-left:6px;" title="${n} anomalía(s) detectada(s) en el último escaneo">⚠ ${n} anomalía(s)</span>`;
})()}</td>
<td><span class="badge ${statusBadgeClass}">${statusLabel}</span></td>
<td><code>${triggerLabel}</code></td>
<td>${dateFormatted}</td>
<td>${updatedFormatted}</td>
<td>${syncedFormatted}</td>
<td style="text-align: center;">
<div style="display: flex; justify-content: center; gap: 12px; align-items: center;">
<a class="btn-icon" title="Abrir en Bucéfalo" href="https://crm.bucefalocrm.io/location/${w.location_id}/workflow/${w.id}" target="_blank" rel="noopener noreferrer" style="background:none; border:none; cursor:pointer; text-decoration:none;">
<i class="fa-solid fa-up-right-from-square" style="font-size: 14px; color: #60A5FA;"></i>
</a>
<button class="btn-icon" title="${toggleTitle}" onclick="toggleWorkflowState('${w.id}', '${w.location_id}', '${w.status}')" style="background:none; border:none; cursor:pointer;">
<i class="fa-solid ${toggleIcon}" style="font-size: 18px;"></i>
</button>
<button class="btn-icon" title="Escanear anomalías en los nodos" onclick="scanWorkflowAnomalies('${w.id}', '${w.location_id}', '${w.name.replace(/'/g, "\\'")}', '${(w.account_name || '').replace(/'/g, "\\'")}')" style="background:none; border:none; cursor:pointer;">
<i class="fa-solid fa-stethoscope" style="font-size: 14px; color:#F59E0B;"></i>
</button>
<button class="btn-icon" title="Renombrar" onclick="openRenameWorkflowModal('${w.id}', '${w.location_id}', '${w.name.replace(/'/g, "\\'")}')" style="background:none; border:none; cursor:pointer;">
<i class="fa-solid fa-pen-to-square text-primary" style="font-size: 14px;"></i>
</button>
<button class="btn-icon" title="Eliminar" onclick="deleteWorkflow('${w.id}', '${w.location_id}', '${w.name.replace(/'/g, "\\'")}')" style="background:none; border:none; cursor:pointer;">
<i class="fa-solid fa-trash-can text-danger" style="font-size: 14px;"></i>
</button>
</div>
</td>
</tr>
`;
}).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 =
`<i class="fa-solid fa-spinner fa-spin"></i> Procesando: <strong>${targetLabel}</strong>`;
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 =
`<i class="fa-solid fa-triangle-exclamation" style="color:#FBBF24;"></i> Lote interrumpido — sesión Bucéfalo expirada`;
document.getElementById("bulk-progress-current").innerHTML =
`<strong style="color:#FBBF24;">Acción requerida:</strong> renueva la sesión arriba y dale a "Reintentar pendientes".`;
} else if (isScanAnomalies && totalAnoms > 0) {
document.getElementById("bulk-progress-title").innerHTML =
`<i class="fa-solid fa-triangle-exclamation" style="color:#F59E0B;"></i> <span style="color:#F59E0B;">${totalAnoms} anomalía(s) detectada(s)</span> en ${workflowsWithAnoms} workflow(s)`;
document.getElementById("bulk-progress-current").innerHTML =
`<span style="color:#FBBF24;">Revisa el detalle abajo y abre cada workflow para corregir.</span>`;
} else if (isScanAnomalies) {
document.getElementById("bulk-progress-title").innerHTML =
`<i class="fa-solid fa-circle-check" style="color:#34D399;"></i> 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 =
`<i class="fa-solid fa-circle-check" style="color:#34D399;"></i> 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 =
`<i class="fa-solid fa-check"></i> ${st.success || 0} escaneados`;
} else {
document.getElementById("bulk-progress-success-count").innerHTML =
`<i class="fa-solid fa-check"></i> ${st.success || 0} OK`;
}
document.getElementById("bulk-progress-skipped-count").innerHTML =
`<i class="fa-solid fa-forward"></i> ${st.skipped || 0} omitidos`;
document.getElementById("bulk-progress-failed-count").innerHTML =
`<i class="fa-solid fa-xmark"></i> ${st.failed || 0} fallos`;
const anomCountEl = document.getElementById("bulk-progress-anomalies-count");
if (isScanAnomalies && totalAnoms > 0) {
anomCountEl.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> ${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
? ` <span style="color:#F59E0B; font-weight:bold;">(${totalAnoms} anomalías detectadas hasta ahora)</span>`
: "";
document.getElementById("bulk-progress-current").innerHTML =
`Trabajando en: <strong>${st.currentName}</strong>${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 ? `<span style="color:${typeColor}; font-weight:bold; margin-right:6px;">${count}×</span>` : "";
const idsHtml = f.ids.length > 0
? `<div style="margin-top: 3px; color: var(--text-muted); font-family: monospace; font-size: 11px; white-space: pre-line;">${f.ids.join("\n")}</div>`
: "";
return `
<div style="padding:6px 8px; background: rgba(255,255,255,0.03); border-left: 3px solid ${typeColor}; border-radius: 4px; font-size: 12px;">
<div style="display:flex; gap:8px; align-items:center;">
<span class="badge" style="background:${typeColor}22; color:${typeColor}; font-size:10px; padding:2px 6px; border-radius:4px;">${typeLabel}</span>
${countPrefix}<strong>${f.node || "(sin nombre)"}</strong>
</div>
${f.desc ? `<div style="margin-top: 3px; color: var(--text-secondary);">${f.desc}</div>` : ""}
${idsHtml}
</div>
`;
}).join("");
const wfUrl = `https://crm.bucefalocrm.io/location/${g.locationId}/workflow/${g.workflowId}`;
return `
<div style="padding:10px; background: rgba(0,0,0,0.25); border-radius: 6px; display:flex; flex-direction:column; gap:6px;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap: wrap;">
<i class="fa-solid fa-diagram-project" style="color:#A78BFA;"></i>
<strong>${g.workflowName}</strong>
<span class="text-muted" style="font-size: 12px;">${g.accountName || g.locationId}</span>
<span class="badge" style="background: rgba(245, 158, 11, 0.18); color:#F59E0B; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:bold;">${g.items.length} anomalía(s)</span>
<a href="${wfUrl}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary" style="margin-left:auto; padding:4px 10px; font-size:12px; text-decoration:none; color:#FFFFFF; background: rgba(96, 165, 250, 0.18); border-color: rgba(96, 165, 250, 0.45);">
<i class="fa-solid fa-up-right-from-square" style="color:#60A5FA;"></i> <span style="color:#FFFFFF;">Abrir en Bucéfalo</span>
</a>
</div>
<div style="display:flex; flex-direction:column; gap:4px;">
${itemsHtml}
</div>
</div>
`;
}).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: <filename>"
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 = '<i class="fa-solid fa-spinner fa-spin"></i> 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 = '<i class="fa-solid fa-circle-exclamation"></i> 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 = `<i class="fa-solid fa-triangle-exclamation"></i> Sesión Bucéfalo: ${ageTxt} (probablemente expirada)`;
el.style.color = "#FBBF24";
} else {
el.innerHTML = `<i class="fa-solid fa-circle-check"></i> 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 2030 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 = '<i class="fa-solid fa-spinner fa-spin"></i> 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 = '<i class="fa-solid fa-spinner fa-spin"></i> 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}&current_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 = `<p class="text-secondary" style="font-size: 13px; margin: 0;">No se encontraron pipelines activos para esta cuenta en SQLite.</p>`;
} 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) => `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; background: rgba(255, 255, 255, 0.01); border-radius: var(--radius-sm); border-left: 3px solid var(--color-primary); margin-bottom: 4px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="color: var(--text-muted); font-size: 11px; font-family: var(--font-mono); width: 16px;">#${index + 1}</span>
<strong style="font-size: 13px; color: var(--text-primary);">${stg.name}</strong>
</div>
<code style="font-size: 11px; color: var(--text-muted);">${stg.id}</code>
</div>
`).join("");
} else {
stagesList = `<p class="text-secondary" style="font-size: 12px; margin: 0; padding-left: 12px;">Sin fases / etapas registradas.</p>`;
}
pipeDiv.innerHTML = `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<h5 style="margin: 0; color: var(--text-primary); font-size: 15px; display: flex; align-items: center; gap: 8px;">
<i class="fa-solid fa-filter" style="color: var(--color-primary-glow);"></i> ${pipe.name}
</h5>
<code style="font-size: 11px; color: var(--text-muted);">${pipe.id}</code>
</div>
<div style="display: flex; flex-direction: column; gap: 6px; padding-left: 8px;">
${stagesList}
</div>
`;
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 `<a href="${url}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir contacto en Bucéfalo">${safeName}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a>`;
}
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 `<a href="${url}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir sucursal en Bucéfalo">${safeName}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a>`;
}
// 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 `<span class="text-muted" style="font-size: 11px;">—</span>`;
}
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 ? ` <span class="text-muted">(TIENDA ${escapeHtml(fm.location_tienda)})</span>` : "";
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
? `<a href="${contactUrl}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir este contacto en su sucursal">${escapeHtml(fullName)}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a>`
: `<span>${escapeHtml(fullName)}</span>`;
return `
<li class="fuzzy-match-item">
<span class="fuzzy-badge ${badge.cls}" title="${escapeHtmlAttr(badge.title)}">${escapeHtml(badge.label)}</span>
${link}
<div class="fuzzy-match-meta">
<span class="fuzzy-match-branch">${escapeHtml(branchName)}${branchTienda}</span>
${dataParts ? `<span class="text-muted" style="font-size: 11px;">${dataParts}</span>` : ""}
</div>
</li>
`;
}).join("");
return `
<details class="fuzzy-matches-cell">
<summary class="fuzzy-matches-summary" title="El escaneo encontró ${count} contacto(s) parecido(s) en otra(s) sucursal(es). Revisa antes de crear: podría ser el mismo contacto registrado con datos ligeramente distintos.">
<i class="fa-solid fa-triangle-exclamation"></i>
${count} posible${count === 1 ? "" : "s"} coincidencia${count === 1 ? "" : "s"}
</summary>
<ul class="fuzzy-matches-list">${itemsHtml}</ul>
</details>
`;
}
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) => `<input type="checkbox" class="comp-bulk-master-checkbox" data-bucket="${uiKey}" title="Seleccionar/deseleccionar todos los visibles">`,
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 `<input type="checkbox" class="comp-bulk-row-checkbox" disabled title="No se pudo resolver la sucursal destino — no es seleccionable para crear en bulk.">`;
}
// 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 `<input type="checkbox" class="comp-bulk-row-checkbox" data-bucket="missing_in_assigned_branch" data-brand-contact-id="${escapeHtmlAttr(id)}" data-expected-location-id="${escapeHtmlAttr(expectedLoc)}" data-name="${escapeHtmlAttr(item.name || "")}" title="Seleccionar para crear en bulk"${checkedAttr}>`;
},
},
{
key: "_create_in_branch",
label: "",
render: (item) => {
const expectedLoc = item.expected_location_id || "";
const expectedName = item.expected_branch_name || "";
if (!expectedLoc) {
return `<button class="btn-micro-create-in-branch is-disabled" disabled title="No se pudo resolver la sucursal esperada de este contacto."><i class="fa-solid fa-user-plus"></i></button>`;
}
return `<button class="btn-micro-create-in-branch" data-action="create-in-expected-branch" data-brand-contact-id="${escapeHtmlAttr(item.id || "")}" data-expected-location-id="${escapeHtmlAttr(expectedLoc)}" data-expected-branch-name="${escapeHtmlAttr(expectedName)}" data-name="${escapeHtmlAttr(item.name || "")}" title="Crear este contacto en ${escapeHtmlAttr(expectedName || expectedLoc)} con todos sus custom fields mapeados."><i class="fa-solid fa-user-plus"></i></button>`;
},
},
{ 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 `<button class="btn-micro-sync-from-branch is-disabled" disabled title="${escapeHtml(reason)}"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>`;
}
return `<button class="btn-micro-sync-from-branch" data-action="update-branch-from-brand" data-brand-contact-id="${escapeHtml(item.id || "")}" data-branch-contact-id="${escapeHtml(cand.id || "")}" data-branch-location-id="${escapeHtml(item.expected_location_id || "")}" data-branch-name="${escapeHtml(item.expected_branch_name || "")}" data-name="${escapeHtml(item.name || "")}" title="Actualizar el contacto en ${escapeHtml(item.expected_branch_name || "la sucursal asignada")} con los datos y custom fields de Marca."><i class="fa-solid fa-arrow-right-arrow-left"></i></button>`;
},
},
],
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 => `<div style="font-size: 12px; line-height: 1.5;">${escapeHtml(b.name || b.location_id)} <span class="text-muted">· TIENDA: ${escapeHtml(b.tienda_value || "—")}</span> <a href="${BUCEFALO_BASE_URL}/v2/location/${b.location_id}/contacts/detail/${b.id}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir contacto en sucursal"><i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a></div>`).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 `<button class="btn-micro-sync-from-branch" data-action="update-brand-tienda" data-brand-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-branches='${branchesAttr}' title="Cambiar la TIENDA del contacto Marca para que apunte a la sucursal donde realmente está."><i class="fa-solid fa-shuffle"></i></button>`;
},
},
],
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 `<div style="font-size: 12px; line-height: 1.5;"><a href="${link}" target="_blank" rel="noopener noreferrer" class="contact-link" title="Abrir contacto en sucursal">${escapeHtml(fullName)}<i class="fa-solid fa-arrow-up-right-from-square contact-link-icon"></i></a><br><span class="text-muted">tel: ${escapeHtml(tel)} · email: ${escapeHtml(mail)}</span></div>`;
},
},
{ key: "opps_in_brand", label: "Opps en Marca", align: "right" },
{
key: "_actions",
label: "",
align: "right",
render: (item) => {
return `<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtmlAttr(item.id || "")}" data-name="${escapeHtmlAttr(item.name || "")}" data-opps="${item.opps_in_brand || 0}" title="Eliminar este duplicado en Marca (irreversible). El contacto bueno permanece en sucursal."><i class="fa-solid fa-trash"></i></button>`;
},
},
],
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 = ` <span class="resolution-badge resolution-tienda" title="Resuelto por campo TIENDA">T</span>`;
} else if (src === "sucursal_exact") {
badge = ` <span class="resolution-badge resolution-sucursal-exact" title="Resuelto por Sucursal (match exacto)">S</span>`;
} else if (src === "sucursal_substring") {
badge = ` <span class="resolution-badge resolution-sucursal-substring" title="Resuelto por Sucursal (substring approx) — verifica que sea el correcto">S~</span>`;
}
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 += `<button class="btn-micro-verify" data-action="verify-in-expected-branch"
data-brand-contact-id="${escapeHtml(item.id || "")}"
data-expected-location-id="${escapeHtml(expectedLoc)}"
data-expected-branch-name="${escapeHtml(expectedName)}"
data-name="${escapeHtml(item.name || "")}"
title="Verificar en SQLite si ya existe un homónimo EXACTO en ${escapeHtml(expectedName || expectedLoc)}. Read-only."><i class="fa-solid fa-magnifying-glass"></i></button>`;
actionBtns += `<button class="btn-micro-create-in-branch" data-action="create-in-expected-branch"
data-brand-contact-id="${escapeHtml(item.id || "")}"
data-expected-location-id="${escapeHtml(expectedLoc)}"
data-expected-branch-name="${escapeHtml(expectedName)}"
data-name="${escapeHtml(item.name || "")}"
title="Crear este contacto en ${escapeHtml(expectedName || expectedLoc)} con todos sus custom fields mapeados."><i class="fa-solid fa-user-plus"></i></button>`;
}
return `${actionBtns}<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-opps="${item.opps_in_brand || 0}" title="Eliminar este contacto en Marca (irreversible)"><i class="fa-solid fa-trash"></i></button>`;
},
},
],
brand_without_tienda: [
{
key: "name",
label: "Nombre",
render: it => {
const testBadge = it.looks_like_test
? ` <span class="resolution-badge resolution-test" title="Detectado como contacto de prueba/test">TEST</span>`
: "";
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 ? `<span class="text-muted">no resuelta</span>` : `<span class="text-muted">—</span>`;
}
const kind = it.sucursal_resolution_kind;
const badge = kind === "exact"
? ` <span class="resolution-badge resolution-sucursal-exact" title="Match exacto del campo Sucursal">S</span>`
: kind === "substring"
? ` <span class="resolution-badge resolution-sucursal-substring" title="Match por substring — verifica que sea el correcto">S~</span>`
: "";
return `${escapeHtml(it.expected_branch_name)}${badge}`;
},
},
{ key: "expected_tienda", label: "TIENDA a escribir", render: it => it.expected_tienda ? escapeHtml(it.expected_tienda) : `<span class="text-muted">—</span>` },
],
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
? `<span class="resolution-badge resolution-test" title="Solo informativo: en Marca este campo se resuelve por matcheo/sync">MARCA</span>`
: `<span class="resolution-badge resolution-sucursal-substring">SUC</span>`;
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 `<span class="resolution-badge resolution-test">vacío</span>`;
}
const len = Number(it.field_len || (it.field_value || "").length);
const cls = (len === 20) ? "" : "resolution-test";
const badge = cls ? ` <span class="resolution-badge ${cls}">len ${len}</span>` : "";
return `<code>${escapeHtml(String(it.field_value))}</code>${badge}`;
},
},
{ key: "reason", label: "Motivo" },
{
key: "_actions",
label: "",
align: "right",
render: (item) => {
if (item.is_brand) {
return `<button class="btn-micro-match-brand-opp-id" data-action="match-brand-opp-id" data-opp-id="${escapeHtml(item.id || "")}" title="Matchear esta opp con su contraparte en sucursal y poblar el campo con el id de la opp de sucursal"><i class="fa-solid fa-arrows-left-right-to-line"></i></button>`;
}
return `<button class="btn-micro-fill-opp-id" data-action="fill-opp-id" data-opp-id="${escapeHtml(item.id || "")}" data-location-id="${escapeHtml(item.location_id || "")}" title="Llenar el campo con el id nativo de esta opp"><i class="fa-solid fa-wand-magic-sparkles"></i></button>`;
},
},
],
contacts_missing_id_field: [
{
key: "location_name",
label: "Cuenta",
render: it => {
const tag = it.is_brand
? `<span class="resolution-badge resolution-test" title="Solo informativo: en Marca este campo se resuelve por matcheo/sync">MARCA</span>`
: `<span class="resolution-badge resolution-sucursal-substring">SUC</span>`;
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 `<span class="resolution-badge resolution-test">vacío</span>`;
}
const len = Number(it.field_len || (it.field_value || "").length);
const cls = (len === 20) ? "" : "resolution-test";
const badge = cls ? ` <span class="resolution-badge ${cls}">len ${len}</span>` : "";
return `<code>${escapeHtml(String(it.field_value))}</code>${badge}`;
},
},
{ key: "reason", label: "Motivo" },
{
key: "_actions",
label: "",
align: "right",
render: (item) => {
if (item.is_brand) {
return `<span class="text-secondary" title="En Marca el campo se llena por matcheo/sync, no manualmente">(solo matcheo)</span>`;
}
return `<button class="btn-micro-fill-contact-id" data-action="fill-contact-id" data-contact-id="${escapeHtml(item.id || "")}" data-location-id="${escapeHtml(item.location_id || "")}" title="Llenar el campo con el id nativo de este contacto"><i class="fa-solid fa-wand-magic-sparkles"></i></button>`;
},
},
],
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 => `<span class="group-count" data-name-norm="${escapeHtml(it.name_norm || "")}">${escapeHtml(String(it.group_size || ""))} con mismo nombre</span>` },
{ 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
? `<button class="btn-micro-sync-from-branch" data-action="sync-from-branch" data-brand-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-name-norm="${escapeHtml(item.name_norm || "")}"${candidatesAttr} title="Sincronizar desde sucursal (solo disponible cuando este sea el único restante del grupo)" hidden><i class="fa-solid fa-arrow-right-arrow-left"></i></button>`
: "";
return `${syncBtn}<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtml(item.id || "")}" data-name="${escapeHtml(item.name || "")}" data-name-norm="${escapeHtml(item.name_norm || "")}" data-opps="${item.opps_in_brand || 0}" title="Eliminar este duplicado (irreversible)"><i class="fa-solid fa-trash"></i></button>`;
},
},
],
brand_duplicate_link_opps: [
{
key: "recommended_action",
label: "",
render: it => it.recommended_action === "keep"
? `<span class="resolution-badge resolution-sucursal-exact" title="Réplica canónica a conservar (el limpiador confirma por createdAt en vivo)">CONSERVAR</span>`
: `<span class="resolution-badge resolution-test" title="Réplica sobrante a eliminar">BORRAR</span>`,
},
{ 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 => `<code>${escapeHtml(String(it.link_value || ""))}</code>`,
},
{ 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 = `<i class="fa-solid fa-circle-notch fa-spin"></i>`;
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 = `<i class="fa-solid fa-triangle-exclamation"></i>`;
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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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 = `<i class="fa-solid fa-rotate"></i> Recalcular`;
}
}
}
function setComparativaPlaceholder(msg) {
const tbody = document.getElementById("comp-per-branch-rows");
if (tbody) tbody.innerHTML = `<tr><td colspan="4" class="text-center">${msg}</td></tr>`;
}
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 => `<li><strong>${escapeHtml(dx.name)}</strong> <span class="text-muted">(${dx.location_id})</span></li>`)
.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 = `<tr><td colspan="4" class="text-center">No hay sucursales para mostrar.</td></tr>`;
} else {
tbody.innerHTML = rows.map((r, idx) => `
<tr>
<td>${idx + 1}</td>
<td>${escapeHtml(r.name)}</td>
<td style="text-align: right;">${fmtNumber(r.contacts)}</td>
<td style="text-align: right;">${fmtNumber(r.opportunities)}</td>
</tr>
`).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
? `<i class="fa-solid fa-chevron-up"></i> Ocultar listado`
: `<i class="fa-solid fa-list"></i> Ver listado`;
}
}
// ---- Bulk selection para buckets de Comparativa ----
// Mantiene el conjunto de brand_contact_ids seleccionados por bucket.
const comparativaBulkSelection = {
// bucket_uiKey -> Set<brand_contact_id>
};
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 <th> 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 = `<i class="fa-solid fa-user-plus"></i> Crear seleccionados en sucursal`;
} else {
headerBtn.disabled = false;
headerBtn.innerHTML = `<i class="fa-solid fa-user-plus"></i> 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 <th> 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 = `<p class="text-secondary">Sin datos. Recalcula la comparativa.</p>`;
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 = `<p class="text-secondary" style="margin: 8px 0;">No hay registros en este grupo. ¡Limpio!</p>`;
body.dataset.rendered = "true";
return;
}
const truncatedNote = bucket.truncated
? `<p class="text-muted" style="margin: 0 0 8px 0; font-size: 12px;">Mostrando los primeros ${bucket.items.length} de ${bucket.total}. Exporta a CSV para verlos todos.</p>`
: "";
const filterId = `comp-filter-${uiKey}`;
const mainTableHtml = `
<div class="table-wrapper">
<table class="data-table comparativa-bucket-table">
<thead>
<tr>${cols.map(c => {
const thStyle = c.align ? ` style="text-align:${c.align}"` : "";
// Las columnas pueden definir headerRender(uiKey) para inyectar
// controles en el <th> (p.ej. master-checkbox de bulk select).
const thContent = (typeof c.headerRender === "function")
? c.headerRender(uiKey)
: c.label;
return `<th${thStyle}>${thContent}</th>`;
}).join("")}</tr>
</thead>
<tbody>
${bucket.items.map(item => `
<tr>${cols.map(c => {
const tdStyle = c.align ? ` style="text-align:${c.align}"` : "";
if (typeof c.render === "function") {
return `<td${tdStyle}>${c.render(item)}</td>`;
}
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 `<td${tdStyle}>${cell}</td>`;
}).join("")}</tr>
`).join("")}
</tbody>
</table>
</div>
`;
// 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 = `
<div class="comparativa-test-subsection">
<h5 class="comparativa-test-subsection-title">
<i class="fa-solid fa-flask-vial"></i>
Detectados como prueba/test
<span class="comparativa-test-count">${testItems.length}</span>
</h5>
<p class="text-secondary comparativa-test-subsection-desc">
Contactos cuyo nombre, email o teléfono contiene <code>test</code>, <code>testing</code>, <code>prueba</code> o <code>pruebas</code>. Elimínalos si confirmas que no son leads reales.
</p>
<div class="table-wrapper">
<table class="data-table comparativa-bucket-table">
<thead>
<tr>
<th>Nombre</th>
<th>Teléfono</th>
<th>Email</th>
<th>Sucursal (CF)</th>
<th style="text-align:right;"></th>
</tr>
</thead>
<tbody>
${testItems.map(it => `
<tr>
<td>${contactLink(it.name, it.id, BRAND_LOCATION_ID_UI)} <span class="resolution-badge resolution-test" title="Detectado como contacto de prueba/test">TEST</span></td>
<td>${escapeHtml(it.phone || "—")}</td>
<td>${escapeHtml(it.email || "—")}</td>
<td>${escapeHtml(it.sucursal || "—")}</td>
<td style="text-align:right;">
<button class="btn-micro-delete" data-action="delete-brand-contact" data-contact-id="${escapeHtml(it.id || "")}" data-name="${escapeHtml(it.name || "")}" data-opps="0" title="Eliminar este contacto de prueba (irreversible)">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
</div>
`;
}
}
// 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}
<div class="comparativa-bucket-filter">
<i class="fa-solid fa-magnifying-glass"></i>
<input type="search" id="${filterId}" placeholder="Filtrar en este listado (nombre, teléfono, email, sucursal...)">
</div>
${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 <strong>${escapeHtmlAttr(name)}</strong> que está en <strong>${escapeHtmlAttr(branchName)}</strong> con los datos y custom fields del contacto Marca.<br><br>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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
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 = '<i class="fa-solid fa-check"></i>';
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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
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 =>
`<li><strong>${escapeHtml(`${m.first_name || ""} ${m.last_name || ""}`.trim() || "(sin nombre)")}</strong> — ${escapeHtml(m.email || "sin email")}${escapeHtml(m.phone || "sin tel")}<br><code style="font-size: 10px;">${escapeHtml(m.id)}</code></li>`
).join("");
await appConfirm({
title: "Encontrado en la sucursal esperada",
severity: "info",
message: `
Se encontraron <strong>${data.match_count}</strong> match(es) EXACTO(s) para
<strong>${escapeHtml(name)}</strong> en <strong>${escapeHtml(expectedBranchName)}</strong>:
<ul style="margin: 8px 0 8px 18px;">${matchesHtml}</ul>
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 = '<i class="fa-solid fa-check"></i>';
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
<strong>${escapeHtml(expectedBranchName)}</strong>.<br><br>
Posibles razones:
<ul style="margin: 6px 0 6px 18px; font-size: 12px;">
<li>El contacto efectivamente no existe en la sucursal.</li>
<li>El nombre difiere por mayúsculas, acentos o espacios (verify exige 100% byte por byte tras normalizar).</li>
<li>SQLite no está sincronizado con la sucursal — corre "Sincronizar Sucursal" y reintenta.</li>
</ul>
`,
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 <strong>${escapeHtml(name)}</strong> en la sucursal
<strong>${escapeHtml(expectedBranchName)}</strong> (${escapeHtml(expectedLocationId)}).<br><br>
Se copian first/last name, email, phone y todos los custom fields mapeados por nombre
desde el schema Marca al schema de la sucursal.<br><br>
${isLive
? "<strong>Atención:</strong> 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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
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 = '<i class="fa-solid fa-check"></i>';
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: `<strong>${escapeHtmlAttr(name)}</strong> aparece en ${branches.length} sucursales. Selecciona aquella cuya <strong>TIENDA</strong> 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 <strong>TIENDA</strong> del contacto Marca <strong>${escapeHtmlAttr(name)}</strong> a <strong>${escapeHtmlAttr(chosen.tienda_value)}</strong> (sucursal ${escapeHtmlAttr(chosen.name)}).<br><br>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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
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 = '<i class="fa-solid fa-check"></i>';
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 <strong>${escapeHtmlAttr(name)}</strong>. 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 <strong>${escapeHtmlAttr(name)}</strong> con los datos y custom fields del contacto en <strong>${escapeHtmlAttr(chosen.branch_name)}</strong>.<br><br>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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
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 = '<i class="fa-solid fa-check"></i>';
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 <strong>${opps} oportunidad${opps !== 1 ? "es" : ""}</strong> asociada${opps !== 1 ? "s" : ""} que también será${opps !== 1 ? "n" : ""} eliminada${opps !== 1 ? "s" : ""}.<br><br>`
: "";
const ok = await appConfirm({
title: "Eliminar contacto Marca",
severity: "danger",
message: `¿Eliminar <strong>${escapeHtmlAttr(name)}</strong> del CRM de Marca?<br><br>${oppsClause}Esta acción es <strong>IRREVERSIBLE</strong>.`,
confirmText: "Eliminar definitivamente",
});
if (!ok) return;
const originalHTML = delBtn.innerHTML;
delBtn.disabled = true;
delBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const actionsHTML = (it.actions || []).map(a => {
if (a.action === "match_existing_contact") return `<span class="sync-action-pill">match contacto (por ${escapeHtml(a.strategy)})</span>`;
if (a.action === "create_contact") return `<span class="sync-action-pill is-create">crear contacto</span>`;
if (a.action === "create_opp") return `<span class="sync-action-pill is-create">crear opp</span>`;
if (a.action === "update_opp") return `<span class="sync-action-pill is-update">actualizar opp ${escapeHtml(a.brand_opp_id || "")}</span>`;
return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
}).join(" ");
return `
<tr class="${statusClass}">
<td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(it.opp_name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.opp_id)}</span></td>
<td>${escapeHtml(it.branch_name)}</td>
<td>${escapeHtml(contactName)}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(contactRef)}</span></td>
<td>${actionsHTML}${errorHTML}</td>
</tr>
`;
},
},
"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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const actionsHTML = (it.actions || []).map(a => {
if (a.action === "skip_already_in_brand") return `<span class="sync-action-pill">ya existe (por ${escapeHtml(a.strategy)})</span>`;
if (a.action === "create_contact") return `<span class="sync-action-pill is-create">crear contacto</span>`;
return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
}).join(" ");
return `
<tr class="${statusClass}">
<td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.branch_contact_id || "")}</span></td>
<td>${escapeHtml(it.branch_name)}</td>
<td><span style="font-size: 12px;">${escapeHtml(dataParts || "—")}</span>${it.opps_in_branch ? `<br><span class="text-muted" style="font-size: 11px;">${it.opps_in_branch} opps locales</span>` : ""}</td>
<td>${actionsHTML}${errorHTML}</td>
</tr>
`;
},
},
"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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const actionsHTML = (it.actions || []).map(a => {
if (a.action === "skip_no_sucursal") return `<span class="sync-action-pill">sin Sucursal</span>`;
if (a.action === "skip_unresolved_sucursal") return `<span class="sync-action-pill">Sucursal '${escapeHtml(a.sucursal || "")}' no mapea</span>`;
if (a.action === "skip_already_has_tienda") return `<span class="sync-action-pill">ya tenía TIENDA='${escapeHtml(a.current_tienda || "")}'</span>`;
if (a.action === "update_tienda") {
const kind = a.resolution_kind === "substring" ? " (substring)" : "";
return `<span class="sync-action-pill is-update">escribir TIENDA${kind}</span>`;
}
return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
}).join(" ");
return `
<tr class="${statusClass}">
<td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.brand_contact_id || "")}</span></td>
<td>${escapeHtml(it.sucursal || "—")}</td>
<td>${escapeHtml(it.expected_branch_name || "—")}</td>
<td>${escapeHtml(it.expected_tienda || "—")}</td>
<td>${actionsHTML}${errorHTML}</td>
</tr>
`;
},
},
"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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const actionsHTML = (it.actions || []).map(a => {
if (a.action === "skip_no_tienda") return `<span class="sync-action-pill">sin TIENDA</span>`;
if (a.action === "skip_unknown_tienda") return `<span class="sync-action-pill">TIENDA '${escapeHtml(a.tienda || "")}' desconocida</span>`;
if (a.action === "skip_already_in_branch") return `<span class="sync-action-pill">ya existe en destino (por ${escapeHtml(a.strategy)})</span>`;
if (a.action === "skip_in_other_branch") return `<span class="sync-action-pill" title="${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')}">ya en otra: ${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')} (por ${escapeHtml(a.strategy)})</span>`;
if (a.action === "create_contact_in_branch") return `<span class="sync-action-pill is-create">crear en sucursal</span>`;
return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
}).join(" ");
return `
<tr class="${statusClass}">
<td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.brand_contact_id || "")}</span></td>
<td>${escapeHtml(it.tienda || "—")}</td>
<td>${escapeHtml(it.target_branch_name || "—")}</td>
<td><span style="font-size: 12px;">${escapeHtml(dataParts || "—")}</span></td>
<td>${actionsHTML}${errorHTML}</td>
</tr>
`;
},
},
// 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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const actionsHTML = (it.actions || []).map(a => {
if (a.action === "skip_no_tienda") return `<span class="sync-action-pill">sin TIENDA</span>`;
if (a.action === "skip_unknown_tienda") return `<span class="sync-action-pill">TIENDA '${escapeHtml(a.tienda || "")}' desconocida</span>`;
if (a.action === "skip_already_in_branch") return `<span class="sync-action-pill">ya existe en destino (por ${escapeHtml(a.strategy)})</span>`;
if (a.action === "skip_in_other_branch") return `<span class="sync-action-pill" title="${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')}">ya en otra: ${escapeHtml(a.located_in_branch_name || a.located_in_location_id || '')} (por ${escapeHtml(a.strategy)})</span>`;
if (a.action === "create_contact_in_branch") return `<span class="sync-action-pill is-create">crear en sucursal</span>`;
return `<span class="sync-action-pill">${escapeHtml(a.action || "")}</span>`;
}).join(" ");
return `
<tr class="${statusClass}">
<td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(it.name || "(sin nombre)")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.brand_contact_id || "")}</span></td>
<td>${escapeHtml(it.tienda || "—")}</td>
<td>${escapeHtml(it.target_branch_name || "—")}</td>
<td><span style="font-size: 12px;">${escapeHtml(dataParts || "—")}</span></td>
<td>${actionsHTML}${errorHTML}</td>
</tr>
`;
},
},
"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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const accountCell = `${escapeHtml(it.account || it.location_id || "")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.location_id || "")}</span>`;
return `
<tr class="${statusClass}">
<td>${accountCell}</td>
<td>${escapeHtml(String(it.contacts_in_scope || 0))}</td>
<td><span class="sync-status-pill ${(it.set || 0) > 0 ? "is-update" : ""}">${escapeHtml(String(it.set || 0))}</span></td>
<td>${escapeHtml(String(it.skipped || 0))}</td>
<td>${escapeHtml(String(it.errors || 0))}${errorHTML}</td>
</tr>
`;
},
},
"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 ? `<div class="sync-row-error"><i class="fa-solid fa-circle-exclamation"></i> ${escapeHtml(it.error)}</div>` : "";
const accountCell = `${escapeHtml(it.account || it.location_id || "")}<br><span class="text-muted" style="font-size: 11px;">${escapeHtml(it.location_id || "")}</span>`;
return `
<tr class="${statusClass}">
<td>${accountCell}</td>
<td>${escapeHtml(String(it.opps_in_scope || 0))}</td>
<td><span class="sync-status-pill ${(it.set || 0) > 0 ? "is-update" : ""}">${escapeHtml(String(it.set || 0))}</span></td>
<td>${escapeHtml(String(it.skipped || 0))}</td>
<td>${escapeHtml(String(it.errors || 0))}${errorHTML}</td>
</tr>
`;
},
},
"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
? `<code title="branch opp id">${escapeHtml(plan.branch_opp_id || "")}</code> <span class="text-muted">(${escapeHtml(plan.matched_by || "")})</span>`
: (st === "review_count_mismatch" || st === "review_ambiguous")
? `M=${plan.M ?? "?"} / N=${plan.N ?? "?"}`
: `<span class="text-muted">${escapeHtml(st)}</span>`;
const loc = plan.branch_location ? `<code style="font-size:11px;">${escapeHtml(plan.branch_location)}</code>` : "—";
return `
<tr class="${cls}">
<td><code style="font-size:11px;">${escapeHtml(it.marca_opp_id || "")}</code><br>${escapeHtml(oppName)}</td>
<td><span class="sync-status-pill ${isMatch ? "is-update" : ""}">${escapeHtml(st)}</span></td>
<td>${detail}</td>
<td>${loc}</td>
</tr>
`;
},
},
"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 = `<code style="font-size:11px;">${escapeHtml(k.id || "")}</code><br><span class="text-muted" style="font-size:11px;">${escapeHtml(k.status || "")} · $${Number(k.monetaryValue || 0).toLocaleString("es-MX")} · ${escapeHtml((k.createdAt || "").slice(0, 10))}</span>`;
const delHTML = dels.map(d => `<code style="font-size:11px;">${escapeHtml(d.id || "")}</code> <span class="text-muted" style="font-size:11px;">(${escapeHtml((d.createdAt || "").slice(0, 10))})</span>`).join("<br>");
return `
<tr class="${statusClass}">
<td><span class="sync-status-pill ${statusClass}">${statusLabel}</span></td>
<td>${escapeHtml(it.name || "(sin nombre)")}</td>
<td>${keepHTML}</td>
<td>${delHTML}</td>
<td><code style="font-size:11px;">${escapeHtml(it.link_value || "")}</code></td>
</tr>
`;
},
},
};
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 = `<p class="text-secondary"><i class="fa-solid fa-spinner fa-spin"></i> Calculando plan... esto consulta el audit completo, puede tomar unos segundos.</p>`;
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 = `<p class="text-danger"><i class="fa-solid fa-circle-exclamation"></i> Error: ${escapeHtml(e.message)}</p>`;
} 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 = `<i class="fa-solid fa-spinner fa-spin"></i> 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 = `<i class="fa-solid fa-spinner fa-spin"></i> ${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 = `<i class="fa-solid fa-circle-exclamation"></i> Error: ${escapeHtml(e.message)}`;
body.prepend(errEl);
}
} finally {
syncBucketState.loading = false;
if (apply) {
apply.disabled = false;
apply.innerHTML = `<i class="fa-solid fa-check"></i> 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
? `<i class="fa-solid fa-check-double" style="color: var(--color-success);"></i> Resultado de la sincronización · ${cfg.bucketTitle}`
: `<i class="fa-solid ${cfg.modalTitleIcon}"></i> 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
? `<i class="fa-solid fa-xmark"></i> Cerrar`
: `Cancelar`;
}
if (!s.candidates) {
body.innerHTML = `<p class="text-secondary">${cfg.emptyMsg}</p>`;
return;
}
const fields = cfg.summaryFields(s, isApplyResult);
const summaryHTML = `
<div class="sync-summary-grid">
${fields.map(f => `<div class="sync-summary-cell ${f.danger ? "is-danger" : ""}"><span>${escapeHtml(f.label)}</span><strong>${f.value}</strong></div>`).join("")}
</div>
${data.run_id ? `<p class="text-muted" style="font-size: 11px; margin: 8px 0 0;">run_id: <code>${escapeHtml(data.run_id)}</code> (disponible para rollback desde Scripts y Auditorías)</p>` : ""}
${isApplyResult ? "" : "<p class='text-secondary' style='margin: 12px 0;'>Revisa el detalle a continuación. Si todo OK, haz click en <strong>Aplicar cambios en GHL</strong>.</p>"}
`;
const detailRows = items.map(it => cfg.renderRow(it, isApplyResult)).join("");
body.innerHTML = `
${summaryHTML}
<div class="sync-detail-wrapper">
<table class="data-table sync-detail-table">
<thead>
<tr>${cfg.columns.map(c => `<th>${escapeHtml(c)}</th>`).join("")}</tr>
</thead>
<tbody>${detailRows}</tbody>
</table>
</div>
`;
}
document.addEventListener("DOMContentLoaded", () => {
bindSyncBucketEvents();
});