6860 lines
323 KiB
JavaScript
6860 lines
323 KiB
JavaScript
// ==========================================================================
|
||
// 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">×</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("&", "&")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">");
|
||
}
|
||
|
||
async function loadContactsTable() {
|
||
const tbody = document.getElementById("contacts-table-rows");
|
||
syncWithoutOppToggle();
|
||
|
||
// El header "Acción" se decide tras conocer si hay contactos test en la pagina.
|
||
// Mostrar header si: (a) filtro sin-opp activo en sucursal (botón Crear opp),
|
||
// (b) hay al menos un contacto test en la pagina actual (botón Eliminar).
|
||
const withoutOppActionApplies = contactsWithoutOppFilter && activeBranchId !== "global";
|
||
const actionHeader = document.getElementById("contacts-action-header");
|
||
|
||
if (activeBranchId === "global") {
|
||
if (actionHeader) actionHeader.style.display = "none";
|
||
tbody.innerHTML = `
|
||
<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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function getScriptBranchOptions() {
|
||
return branchesData
|
||
.filter(b => b.type === "branch")
|
||
.map(b => {
|
||
const displayName = b.nombre.replace(/^\d+\s*-\s*MP\s*-\s*/, "");
|
||
return `
|
||
<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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function setCardState(card, state, btnLabel) {
|
||
if (!card) return;
|
||
card.classList.remove("is-running", "is-success", "is-failed");
|
||
const btn = card.querySelector(".btn-run-script");
|
||
if (btn) {
|
||
btn.disabled = (state === "running");
|
||
btn.innerHTML = btnLabel;
|
||
}
|
||
if (state !== "idle") {
|
||
card.classList.add("is-" + state);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// LiveTerminal singleton — terminal de scripts. Se crea una vez y se reutiliza
|
||
// entre runs (un reset() limpia entre ejecuciones). El módulo live_terminal.js
|
||
// se incluye antes que app.js, así window.LiveTerminal siempre existe.
|
||
// ---------------------------------------------------------------------------
|
||
function ensureLiveTerminal() {
|
||
if (window.activeLiveTerminal) return window.activeLiveTerminal;
|
||
const screen = document.getElementById("terminal-screen");
|
||
if (!screen || !window.LiveTerminal) return null;
|
||
const term = new window.LiveTerminal(screen, {
|
||
maxLines: 3000,
|
||
onError: (text) => recordTerminalError(text),
|
||
onStats: (stats) => updateRunCounter(stats),
|
||
// onEof y onStreamError los seteamos dinámicamente en attachStream
|
||
// según el run activo (ver executeScript).
|
||
});
|
||
// El badge "Volver al final" se muestra/oculta según userScrolledUp.
|
||
// Como el flush ya hace el toggle, lo seguimos con un listener adicional
|
||
// de scroll (passive) para reflejar al instante el estado del usuario.
|
||
screen.addEventListener("scroll", () => {
|
||
const badge = document.getElementById("terminal-resume-scroll");
|
||
if (!badge) return;
|
||
const atBottom = screen.scrollTop + screen.clientHeight >= screen.scrollHeight - 40;
|
||
if (atBottom) badge.classList.add("hidden");
|
||
else if (window.activeEventSource) badge.classList.remove("hidden");
|
||
}, { passive: true });
|
||
window.activeLiveTerminal = term;
|
||
return term;
|
||
}
|
||
|
||
function updateRunCounter(stats) {
|
||
const el = document.getElementById("terminal-run-counter");
|
||
if (!el || !stats) return;
|
||
const fmt = (n) => n.toLocaleString("es-MX");
|
||
if (stats.truncated > 0) {
|
||
el.innerHTML = `${fmt(stats.visible)} líneas · <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, '"')}" data-account="${(w.account_name || '').replace(/"/g, '"')}" ${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 20–30 s en llegar." };
|
||
if (/Esperando código OTP por IMAP/.test(line)) return { title: "Esperando el código en el correo…", detail: "Leyendo INBOX por IMAP." };
|
||
const codeMatch = line.match(/Código obtenido:\s*(\d{6})/);
|
||
if (codeMatch) return { title: "Código recibido", detail: `Pegando ${codeMatch[1]} en el formulario…` };
|
||
if (/Código tipeado en/.test(line)) return { title: "Verificando código…", detail: "" };
|
||
if (/Login completado/.test(line)) return { title: "Login completado", detail: "Guardando sesión…" };
|
||
if (/Lanzando navegador Chromium/.test(line)) return { title: "Abriendo navegador…", detail: "Completa login + MFA en la ventana." };
|
||
if (/Esperando hasta \d+ s a que completes login/.test(line)) return { title: "Esperando tu login…", detail: "Completa login + MFA en la ventana del navegador." };
|
||
if (/Login detectado/.test(line)) return { title: "Login detectado", detail: "Estabilizando cookies…" };
|
||
return null;
|
||
}
|
||
|
||
async function refreshBucefaloSession() {
|
||
const ok = await appConfirm({
|
||
title: "Renovar sesión Bucéfalo",
|
||
message: "Si configuraste .env con credenciales, el login es automático (sin ventana visible). Si no, se abre una ventana del navegador para que completes login + MFA.",
|
||
confirmText: "Renovar",
|
||
severity: "info",
|
||
});
|
||
if (!ok) return;
|
||
|
||
const btn = document.getElementById("btn-refresh-session");
|
||
const oldHtml = btn ? btn.innerHTML : null;
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<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}¤t_status=${currentStatus}`, { method: "POST" })
|
||
.then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); })
|
||
.then(data => {
|
||
showToast("Proceso iniciado en segundo plano.", "success");
|
||
if (data.task_id) monitorPlaywrightTask(data.task_id);
|
||
})
|
||
.catch(err => showToast(err.message, "error"));
|
||
}
|
||
|
||
function openRenameWorkflowModal(workflowId, locationId, currentName) {
|
||
document.getElementById("rename-wf-id").value = workflowId;
|
||
document.getElementById("rename-wf-location").value = locationId;
|
||
document.getElementById("rename-wf-input").value = currentName;
|
||
document.getElementById("modal-rename-workflow").classList.remove("hidden");
|
||
}
|
||
|
||
function closeRenameWorkflowModal() {
|
||
document.getElementById("modal-rename-workflow").classList.add("hidden");
|
||
}
|
||
|
||
function submitRenameWorkflow() {
|
||
const wfId = document.getElementById("rename-wf-id").value;
|
||
const locId = document.getElementById("rename-wf-location").value;
|
||
const newName = document.getElementById("rename-wf-input").value.trim();
|
||
if (!newName) { showToast("El nombre es requerido.", "error"); return; }
|
||
|
||
closeRenameWorkflowModal();
|
||
showToast("Disparando renombrado en GHL...", "info");
|
||
|
||
mutateFetch(`/api/workflows/${wfId}/rename?location_id=${locId}&new_name=${encodeURIComponent(newName)}`, { method: "POST" })
|
||
.then(res => { if (!res.ok) throw new Error("Fallo al iniciar"); return res.json(); })
|
||
.then(data => {
|
||
showToast("Proceso iniciado.", "success");
|
||
if (data.task_id) monitorPlaywrightTask(data.task_id);
|
||
})
|
||
.catch(err => showToast(err.message, "error"));
|
||
}
|
||
|
||
function openLocationInfoModal() {
|
||
if (activeBranchId === "global") {
|
||
showToast("Estás en la vista global consolidada. Elige una sucursal específica para ver sus pipelines.", "info");
|
||
return;
|
||
}
|
||
|
||
// Resetear/limpiar modal anterior
|
||
document.getElementById("location-info-loader").classList.remove("hidden");
|
||
document.getElementById("location-info-content").classList.add("hidden");
|
||
document.getElementById("location-pipelines-container").innerHTML = "";
|
||
|
||
document.getElementById("modal-location-info").classList.remove("hidden");
|
||
|
||
// Obtener información de la ubicación/cuenta de GHL + local
|
||
fetch(`/api/location/${activeBranchId}`)
|
||
.then(res => {
|
||
if (!res.ok) throw new Error("Error al consultar detalles de la cuenta");
|
||
return res.json();
|
||
})
|
||
.then(data => {
|
||
const local = data.local_data || {};
|
||
const ghl = data.ghl_data || {};
|
||
|
||
document.getElementById("loc-info-name").innerText = ghl.name || local.nombre || "-";
|
||
document.getElementById("loc-info-id").innerText = local.location_id || activeBranchId;
|
||
document.getElementById("loc-info-owner").innerText = ghl.business?.name || local.company_owner || "Monte Providencia";
|
||
document.getElementById("loc-info-phone").innerText = ghl.phone || "-";
|
||
document.getElementById("loc-info-email").innerText = ghl.email || "-";
|
||
document.getElementById("loc-info-timezone").innerText = ghl.timezone || "-";
|
||
|
||
const fullAddress = [ghl.address, ghl.city, ghl.state, ghl.country, ghl.postalCode]
|
||
.filter(Boolean)
|
||
.join(", ") || "No disponible";
|
||
document.getElementById("loc-info-address").innerText = fullAddress;
|
||
|
||
// Ahora cargar pipelines de la misma cuenta/sucursal
|
||
return fetch(`/api/pipelines/${activeBranchId}`);
|
||
})
|
||
.then(res => {
|
||
if (!res.ok) throw new Error("Error al consultar los pipelines de la cuenta");
|
||
return res.json();
|
||
})
|
||
.then(data => {
|
||
const pipelines = data.pipelines || [];
|
||
const pipelinesContainer = document.getElementById("location-pipelines-container");
|
||
pipelinesContainer.innerHTML = "";
|
||
|
||
if (pipelines.length === 0) {
|
||
pipelinesContainer.innerHTML = `<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();
|
||
});
|
||
|