// ==========================================================================
// CLIENT ENGINE - MP MANAGER
// ==========================================================================
let branchesData = [];
let activeBranchId = "global"; // Default: Consolidado Global
let activePipelineId = "";
let currentContactsPage = 1;
let contactsSearchQuery = "";
let activeTab = "tab-dashboard";
let contactsWithoutOppFilter = false;
let isSyncingGlobally = false;
let syncProgressInterval = null;
let activeScriptsCategory = "all";
let scriptsSearchQuery = "";
// ==========================================================================
// APP MODE — Toggle global Dry-Run / Live
// ==========================================================================
// Modo universal de la plataforma. Cuando el usuario lo cambia, TODAS las
// acciones mutantes lo respetan: el frontend inyecta el header X-Dry-Run en
// cada fetch mutante (usar mutateFetch), y el backend chequea ese header para
// ramificar entre simulación y escritura real en Bucéfalo.
//
// Default: dry-run (más seguro). Persiste en localStorage entre sesiones.
// Cualquier cambio dispara el evento "app-mode-changed" para que componentes
// suelten su UI específica (ej. el botón de Scripts cambia su label).
const APP_MODE_STORAGE_KEY = "mp_manager_app_mode";
window.AppMode = (function () {
function read() {
try {
const raw = localStorage.getItem(APP_MODE_STORAGE_KEY);
if (raw === "live") return "live";
return "dryrun";
} catch (_) {
return "dryrun";
}
}
function write(mode) {
try { localStorage.setItem(APP_MODE_STORAGE_KEY, mode); } catch (_) {}
}
let current = read();
return {
get isDryRun() { return current === "dryrun"; },
get isLive() { return current === "live"; },
get value() { return current; },
set(mode) {
const next = mode === "live" ? "live" : "dryrun";
if (next === current) return;
current = next;
write(next);
window.dispatchEvent(new CustomEvent("app-mode-changed", { detail: { mode: next } }));
},
toggle() { this.set(current === "dryrun" ? "live" : "dryrun"); },
};
})();
/**
* fetch wrapper para llamadas MUTANTES (POST/PUT/PATCH/DELETE).
* Inyecta el header X-Dry-Run según el modo global.
*
* Reglas:
* - SIEMPRE usar mutateFetch para acciones que escriben en Bucéfalo o que
* llaman scripts mutadores (no GETs ni endpoints read-only).
* - El backend respeta el header. Si tu endpoint aún no lo respeta, sigue
* ejecutando real — el header es informativo. Hay que actualizar el
* endpoint para que ramifique.
*/
async function mutateFetch(url, options = {}) {
const headers = new Headers(options.headers || {});
headers.set("X-Dry-Run", window.AppMode.isDryRun ? "1" : "0");
headers.set("X-App-Mode", window.AppMode.value);
return fetch(url, { ...options, headers });
}
/**
* Inicializa el toggle global del header. Sincroniza estado, dispara eventos,
* actualiza estilos. Se llama una vez en DOMContentLoaded.
*/
function initGlobalModeToggle() {
const wrapper = document.getElementById("global-mode-toggle");
const checkbox = document.getElementById("global-mode-checkbox");
const valueLabel = document.getElementById("global-mode-value");
if (!wrapper || !checkbox || !valueLabel) return;
function syncUI() {
const isDryRun = window.AppMode.isDryRun;
checkbox.checked = !isDryRun; // checked = live (peligroso)
wrapper.classList.toggle("is-dryrun", isDryRun);
wrapper.classList.toggle("is-live", !isDryRun);
valueLabel.textContent = isDryRun ? "Simulación" : "Live (Real)";
wrapper.title = isDryRun
? "Modo Simulación (Dry-Run): las acciones se simulan sin tocar Bucéfalo. Cambia a Live para escribir real."
: "MODO LIVE: las acciones modifican Bucéfalo de verdad. Cambia a Simulación para volver a un modo seguro.";
const icon = wrapper.querySelector(".global-mode-icon i");
if (icon) {
icon.className = isDryRun ? "fa-solid fa-shield-halved" : "fa-solid fa-bolt";
}
}
// Click en el wrapper o en el checkbox alterna el modo.
function handleToggle(e) {
if (e && e.target && (e.target.closest("input[type=checkbox]") || e.target.closest(".toggle-switch"))) {
// El checkbox/switch maneja su propio click; nosotros respondemos al change.
return;
}
e?.stopPropagation();
confirmAndToggle();
}
async function confirmAndToggle() {
const goingToLive = window.AppMode.isDryRun;
if (goingToLive) {
const ok = await appConfirm({
title: "Activar modo LIVE",
severity: "danger",
message: `
Vas a salir del modo simulación. A partir de ahora, todas las acciones
de la plataforma escribirán en Bucéfalo de verdad (eliminar
contactos, crear oportunidades, sincronizar Marca↔Sucursal, etc.).
Si tu intención es probar primero, mantén el modo en Simulación.
`,
confirmText: "Sí, activar Live",
cancelText: "Quedarme en Simulación",
});
if (!ok) return;
}
window.AppMode.toggle();
}
wrapper.addEventListener("click", handleToggle);
checkbox.addEventListener("change", () => {
// checked = live. Si pasó de dryrun → live, confirmar.
if (checkbox.checked && window.AppMode.isDryRun) {
// revertir UI temporalmente; confirmAndToggle decide.
checkbox.checked = false;
confirmAndToggle();
} else if (!checkbox.checked && window.AppMode.isLive) {
window.AppMode.set("dryrun");
}
});
window.addEventListener("app-mode-changed", syncUI);
syncUI();
}
// --- INICIALIZACIÓN ---
document.addEventListener("DOMContentLoaded", () => {
initTabNavigation();
initEventListeners();
initGlobalModeToggle();
initUniversalDryRunToggle(); // Legacy: el toggle del tab Scripts queda como espejo del global.
loadBranches().then(() => {
selectBranch(activeBranchId);
checkGlobalSyncProgress(); // Verificar si ya hay sync corriendo al cargar
});
});
// --- SISTEMA DE TABS ---
function initTabNavigation() {
const tabs = document.querySelectorAll(".tab-link");
updateTabLayoutMode();
tabs.forEach(tab => {
tab.addEventListener("click", () => {
tabs.forEach(t => t.classList.remove("active"));
tab.classList.add("active");
const targetTab = tab.getAttribute("data-tab");
activeTab = targetTab;
document.querySelectorAll(".tab-section").forEach(sec => {
sec.classList.remove("active");
});
document.getElementById(targetTab).classList.add("active");
updateTabLayoutMode();
// Cargar datos según la pestaña activa
refreshActiveView();
});
});
}
function updateTabLayoutMode() {
const wrapper = document.querySelector(".tab-content-wrapper");
if (!wrapper) return;
wrapper.classList.toggle("scripts-mode", activeTab === "tab-scripts");
}
function refreshActiveView() {
if (activeTab === "tab-dashboard") {
loadDashboardMetrics();
} else if (activeTab === "tab-comparativa") {
loadComparativaMarcaSucursales();
} else if (activeTab === "tab-contacts") {
currentContactsPage = 1;
loadContactsTable();
} else if (activeTab === "tab-opportunities") {
loadPipelinesAndBoard();
} else if (activeTab === "tab-scripts") {
loadScriptsCatalog();
} else if (activeTab === "tab-workflows") {
loadWorkflowsTable();
}
}
// --- EVENT LISTENERS ---
function initEventListeners() {
// Info de la sucursal activa en el Header
const activeInfo = document.querySelector(".active-info");
if (activeInfo) {
activeInfo.removeAttribute("title");
}
// Buscador de sucursales en Sidebar
const searchInput = document.getElementById("branch-search");
searchInput.addEventListener("input", (e) => {
filterBranches(e.target.value);
});
// Buscador en la tabla Master Global
const globalMasterSearch = document.getElementById("global-master-search");
if (globalMasterSearch) {
globalMasterSearch.addEventListener("input", (e) => {
renderGlobalMasterTable(e.target.value);
});
}
// Sincronización Global
document.getElementById("btn-sync-global").addEventListener("click", triggerGlobalSync);
const syncMenuBtn = document.getElementById("btn-sync-menu");
const syncMenu = document.getElementById("sync-menu");
const syncMetadataBtn = document.getElementById("btn-sync-metadata");
if (syncMenuBtn && syncMenu) {
syncMenuBtn.addEventListener("click", (event) => {
event.stopPropagation();
syncMenu.classList.toggle("hidden");
});
document.addEventListener("click", () => syncMenu.classList.add("hidden"));
}
if (syncMetadataBtn) {
syncMetadataBtn.addEventListener("click", (event) => {
event.stopPropagation();
syncMenu.classList.add("hidden");
triggerMetadataSync();
});
}
// Sincronización Sucursal Activa
document.getElementById("btn-sync-active").addEventListener("click", triggerActiveBranchSync);
// Buscador en Tabla de Contactos
const contactSearch = document.getElementById("contact-table-search");
contactSearch.addEventListener("input", (e) => {
contactsSearchQuery = e.target.value;
currentContactsPage = 1;
// debounce simple
clearTimeout(contactSearch.timeout);
contactSearch.timeout = setTimeout(() => {
loadContactsTable();
}, 300);
});
// Toggle "Solo sin oportunidad" en la pestaña Contactos
const withoutOppBtn = document.getElementById("btn-filter-without-opp");
if (withoutOppBtn) {
withoutOppBtn.addEventListener("click", () => {
if (activeBranchId === "global") return;
contactsWithoutOppFilter = !contactsWithoutOppFilter;
currentContactsPage = 1;
loadContactsTable();
});
}
// Botón bulk: crear oportunidades para todos los contactos sin opp.
const bulkCreateBtn = document.getElementById("btn-bulk-create-opps");
if (bulkCreateBtn) {
bulkCreateBtn.addEventListener("click", () => bulkCreateOpportunities());
}
// Paginación de Contactos
document.getElementById("btn-page-prev").addEventListener("click", () => {
if (currentContactsPage > 1) {
currentContactsPage--;
loadContactsTable();
}
});
document.getElementById("btn-page-next").addEventListener("click", () => {
currentContactsPage++;
loadContactsTable();
});
// Selector de Pipeline en Kanban
document.getElementById("pipeline-select").addEventListener("change", (e) => {
activePipelineId = e.target.value;
loadKanbanBoard();
});
// Pestañas internas de la terminal (Salida / Errores)
initTerminalTabs();
// Limpiar terminal — ahora va por el LiveTerminal (vacía buffer + DOM).
document.getElementById("btn-clear-terminal").addEventListener("click", () => {
const term = ensureLiveTerminal();
term.reset();
const screen = document.getElementById("terminal-screen");
// Repintar placeholder informativo.
const placeholder = document.createElement("div");
placeholder.className = "terminal-placeholder";
placeholder.innerHTML = '
Terminal Limpia
Selecciona un script y presiona ejecutar para ver resultados aquí.';
screen.appendChild(placeholder);
// Ocultar bar de run (no hay run activo)
const runBar = document.getElementById("terminal-run-bar");
if (runBar) runBar.classList.add("hidden");
const dlBtn = document.getElementById("btn-download-log");
if (dlBtn) dlBtn.classList.add("hidden");
clearTerminalErrors();
setTerminalState("idle", "Listo");
if (window.activeExecutingCard) {
setCardState(window.activeExecutingCard, "idle", ` Ejecutar`);
window.activeExecutingCard = null;
}
});
// Badge "Volver al final" — hace scroll al fondo y re-engancha el auto-scroll.
const resumeScrollBtn = document.getElementById("terminal-resume-scroll");
if (resumeScrollBtn) {
resumeScrollBtn.addEventListener("click", () => {
const term = ensureLiveTerminal();
term.scrollToBottom();
resumeScrollBtn.classList.add("hidden");
});
}
// Botón "Descargar log completo" — apunta al endpoint persistente del run activo.
const downloadLogBtn = document.getElementById("btn-download-log");
if (downloadLogBtn) {
downloadLogBtn.addEventListener("click", () => {
const taskId = downloadLogBtn.getAttribute("data-task-id");
if (!taskId) {
showToast("No hay un run activo del cual descargar el log.", "info");
return;
}
window.location.href = `/api/scripts/logs/${encodeURIComponent(taskId)}`;
});
}
// Copiar terminal (respeta la pestaña activa)
document.getElementById("btn-copy-terminal").addEventListener("click", () => {
const activeTab = getActiveTerminalTab();
const textToCopy = activeTab === "errors"
? buildErrorsCopyText()
: buildOutputCopyText();
if (!textToCopy) {
showToast(activeTab === "errors" ? "No hay errores para copiar." : "No hay logs para copiar.", "info");
return;
}
function fallbackCopyText(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showToast("Logs copiados al portapapeles.", "success");
} else {
showToast("No se pudo copiar los logs.", "error");
}
} catch (err) {
showToast("Error al copiar logs.", "error");
}
document.body.removeChild(textArea);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textToCopy).then(() => {
showToast("Logs copiados al portapapeles.", "success");
}).catch(err => {
fallbackCopyText(textToCopy);
});
} else {
fallbackCopyText(textToCopy);
}
});
// Filtros de Workflows
const workflowSearch = document.getElementById("workflow-table-search");
if (workflowSearch) {
workflowSearch.addEventListener("input", () => {
renderWorkflowsTable();
});
}
const workflowBranchSelect = document.getElementById("workflow-branch-select");
if (workflowBranchSelect) {
workflowBranchSelect.addEventListener("change", () => {
renderWorkflowsTable();
});
}
const workflowStatusSelect = document.getElementById("workflow-status-select");
if (workflowStatusSelect) {
workflowStatusSelect.addEventListener("change", () => {
renderWorkflowsTable();
});
}
const workflowDateField = document.getElementById("workflow-date-field");
if (workflowDateField) {
workflowDateField.addEventListener("change", () => renderWorkflowsTable());
}
const workflowDateAfter = document.getElementById("workflow-date-after");
if (workflowDateAfter) {
workflowDateAfter.addEventListener("change", () => renderWorkflowsTable());
}
const workflowDateBefore = document.getElementById("workflow-date-before");
if (workflowDateBefore) {
workflowDateBefore.addEventListener("change", () => renderWorkflowsTable());
}
const btnClearDates = document.getElementById("btn-workflow-clear-dates");
if (btnClearDates) {
btnClearDates.addEventListener("click", () => {
if (workflowDateAfter) workflowDateAfter.value = "";
if (workflowDateBefore) workflowDateBefore.value = "";
renderWorkflowsTable();
});
}
const btnSyncWorkflows = document.getElementById("btn-sync-workflows");
if (btnSyncWorkflows) {
btnSyncWorkflows.addEventListener("click", triggerWorkflowsSync);
}
const btnExportWorkflows = document.getElementById("btn-export-workflows");
if (btnExportWorkflows) {
btnExportWorkflows.addEventListener("click", exportWorkflowsExcel);
}
const btnRefreshSession = document.getElementById("btn-refresh-session");
if (btnRefreshSession) {
btnRefreshSession.addEventListener("click", refreshBucefaloSession);
}
const btnBulkDraft = document.getElementById("btn-bulk-draft");
if (btnBulkDraft) btnBulkDraft.addEventListener("click", () => triggerBulkToggle("draft"));
const btnBulkPublish = document.getElementById("btn-bulk-publish");
if (btnBulkPublish) btnBulkPublish.addEventListener("click", () => triggerBulkToggle("publish"));
const btnBulkDelete = document.getElementById("btn-bulk-delete");
if (btnBulkDelete) btnBulkDelete.addEventListener("click", () => triggerBulkToggle("delete"));
const btnBulkScanAnomalies = document.getElementById("btn-bulk-scan-anomalies");
if (btnBulkScanAnomalies) btnBulkScanAnomalies.addEventListener("click", triggerBulkScanAnomalies);
const btnBulkAnomaliesToggle = document.getElementById("btn-bulk-anomalies-toggle");
if (btnBulkAnomaliesToggle) btnBulkAnomaliesToggle.addEventListener("click", bulkToggleAnomaliesList);
const btnBulkCancel = document.getElementById("btn-bulk-cancel");
if (btnBulkCancel) btnBulkCancel.addEventListener("click", cancelBulkScan);
const btnBulkClear = document.getElementById("btn-bulk-clear");
if (btnBulkClear) btnBulkClear.addEventListener("click", clearWorkflowSelection);
const btnBulkCloseProgress = document.getElementById("btn-bulk-close-progress");
if (btnBulkCloseProgress) btnBulkCloseProgress.addEventListener("click", bulkExitProgressMode);
const btnBulkRetryPending = document.getElementById("btn-bulk-retry-pending");
if (btnBulkRetryPending) btnBulkRetryPending.addEventListener("click", retryBulkPending);
const selectAll = document.getElementById("workflows-select-all");
if (selectAll) selectAll.addEventListener("change", toggleWorkflowsSelectAll);
const scriptSearchInput = document.getElementById("script-search-input");
if (scriptSearchInput) {
scriptSearchInput.addEventListener("input", (e) => {
scriptsSearchQuery = e.target.value.trim().toLowerCase();
renderScriptsByFilter(activeScriptsCategory);
});
}
}
function initUniversalDryRunToggle() {
// El toggle de la terminal de Scripts ya NO es un control independiente:
// es un espejo del estado global (window.AppMode) y reflejo visual.
// Toda interacción real ocurre desde el toggle del header global.
const checkbox = document.getElementById("universal-dryrun-checkbox");
const wrapper = document.getElementById("universal-dryrun-wrapper");
if (!checkbox || !wrapper) return;
// Reemplaza la etiqueta para dejar claro que es un reflejo, no un control aparte.
const label = wrapper.querySelector(".toggle-label");
if (label) label.textContent = "Modo (global)";
function sync() {
const isDryRun = window.AppMode.isDryRun;
checkbox.checked = isDryRun; // mantenemos su semántica original: checked = dry-run
wrapper.classList.toggle("is-active", isDryRun);
wrapper.classList.toggle("is-danger", !isDryRun);
wrapper.title = isDryRun
? "Modo Simulación (controlado desde el toggle global del header)."
: "MODO LIVE — los scripts ejecutan cambios reales en Bucéfalo.";
}
// El checkbox interno no edita el estado: redirige al toggle global.
checkbox.addEventListener("change", (e) => {
// Cualquier cambio aquí redirige al toggle global (que pide confirmación si va a live).
e.preventDefault();
const desiredDryRun = checkbox.checked;
if (desiredDryRun && window.AppMode.isLive) {
window.AppMode.set("dryrun");
} else if (!desiredDryRun && window.AppMode.isDryRun) {
// Disparar la confirmación del global haciendo click en el header.
document.getElementById("global-mode-toggle")?.click();
// restaurar visual hasta que el global decida.
sync();
}
});
window.addEventListener("app-mode-changed", sync);
sync();
}
// --- NOTIFICACIONES TOAST ---
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
const toast = document.createElement("div");
toast.className = `toast ${type}`;
let icon = "info-circle";
if (type === "success") icon = "check-circle";
if (type === "error") icon = "triangle-exclamation";
toast.innerHTML = `
${message}
`;
container.appendChild(toast);
// Auto-eliminar
setTimeout(() => {
toast.style.animation = "slideIn 0.3s ease reverse";
setTimeout(() => {
toast.remove();
}, 300);
}, 4000);
}
// --- MODALES CUSTOM (appConfirm / appPrompt) ---
// Reemplazan los pop-ups nativos del browser (confirm/prompt/alert).
// Promise-based: `const ok = await appConfirm({...});`
function _createAppModal({ title, severity = 'info', bodyHTML, footerHTML }) {
const iconByService = {
danger: 'triangle-exclamation',
warning: 'circle-exclamation',
info: 'circle-info',
};
const icon = iconByService[severity] || 'circle-info';
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
`;
}
}
/**
* Dispara la migración de oportunidades del pipeline más antiguo al más reciente
* en la sucursal indicada. Flujo:
* 1) Pide preview al backend en modo dry-run (sin escribir).
* 2) Muestra el plan al usuario en modal interno con appConfirm.
* 3) Si aprueba Y el toggle global está en Live: aplica con escritura real.
* Si el toggle global está en Dry-Run: solo muestra el plan, no aplica.
* 4) Toast con resumen + refresh del overview.
*/
async function dedupePipelinesForBranch(btn) {
const locationId = btn.dataset.locationId;
if (!locationId) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` Calculando plan…`;
try {
// 1. Preview en dry-run, sin importar el toggle global (preview es siempre seguro).
const previewRes = await fetch(`/api/pipelines/${encodeURIComponent(locationId)}/dedupe`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Dry-Run": "1" },
body: "{}",
});
const preview = await previewRes.json();
if (!previewRes.ok) {
const detail = (preview && preview.detail) || "No se pudo calcular el plan.";
showToast(typeof detail === "string" ? detail : JSON.stringify(detail), "error");
return;
}
const plans = preview.plans || [];
if (plans.length === 0) {
await appConfirm({
title: "Sin pipelines duplicados",
severity: "info",
message: preview.message || "Los pipelines de esta sucursal no tienen nombres normalizados coincidentes. Si quieres migrar entre dos pipelines específicos con nombres distintos, hay que usar el script CLI con --from-pipeline y --to-pipeline.",
confirmText: "Entendido",
cancelText: "Cerrar",
});
return;
}
// 2. Renderizar el plan en modal.
const fmtDate = (iso) => iso ? iso.replace("T", " ").substring(0, 16) + " UTC" : "—";
const plansHTML = plans.map((p, idx) => {
const mappingHtml = (p.mapping || []).map(m =>
`
`
).join("");
// Breakdown por flujo (puede no venir si el backend es viejo).
const plain = p.plain_move_count ?? p.opportunities_count;
const mergeObs = p.merge_obsolete_won_count ?? 0;
const mergeOff = p.merge_official_won_count ?? 0;
const breakdownHtml = (mergeObs + mergeOff > 0)
? `
Duplicados detectados — resolución automática
• ${plain} opps se moverían directamente (sin duplicado).
• ${mergeObs} opps el obsoleto gana (etapa más avanzada): sus custom fields se copian a la oficial, la oficial se actualiza al stage avanzado, y la obsoleta se elimina.
• ${mergeOff} opps la oficial gana (ya estaba más avanzada): la oficial queda intacta, la obsoleta simplemente se elimina.
`
: "";
return `
Plan ${idx + 1}
Origen (obsoleto): ${escapeHtmlAttr(p.obsolete.name)} ${escapeHtmlAttr(p.obsolete.id)}
`;
tbody.appendChild(tr);
});
} catch (e) {
console.error("Error cargando historial de sincronización", e);
}
}
// --- 2. CONTACTOS CONTROLLER ---
/**
* Refresca el estado visual del botón "Solo sin oportunidad" y la línea de status
* según `contactsWithoutOppFilter` y la sucursal activa.
*/
function syncWithoutOppToggle() {
const btn = document.getElementById("btn-filter-without-opp");
const status = document.getElementById("contacts-filter-status");
if (!btn) return;
const isGlobal = activeBranchId === "global";
btn.disabled = isGlobal;
btn.style.opacity = isGlobal ? "0.5" : "1";
const active = contactsWithoutOppFilter && !isGlobal;
btn.classList.toggle("btn-primary", active);
btn.classList.toggle("btn-secondary", !active);
btn.setAttribute("aria-pressed", String(active));
btn.innerHTML = active
? ` Filtrando: sin oportunidad `
: ` Solo sin oportunidad`;
// El botón bulk de "Crear oportunidades para todos" solo es visible cuando
// está activo el filtro "Solo sin oportunidad" en una sucursal específica
// y hay al menos un contacto sin opp en esa sucursal.
const bulkBtn = document.getElementById("btn-bulk-create-opps");
if (bulkBtn) {
let show = false;
let count = 0;
if (active) {
const b = branchesData.find(x => x.location_id === activeBranchId);
count = b?.metrics?.contacts_without_opp_count ?? 0;
show = count > 0;
}
bulkBtn.style.display = show ? "" : "none";
if (show) {
bulkBtn.innerHTML = ` Crear oportunidades para los ${count}`;
}
}
if (status) {
if (isGlobal) {
status.textContent = "Selecciona una sucursal para usar este filtro.";
} else if (active) {
const b = branchesData.find(x => x.location_id === activeBranchId);
const n = b?.metrics?.contacts_without_opp_count ?? 0;
status.textContent = `Mostrando ${n} contactos sin oportunidad en esta sucursal.`;
} else {
status.textContent = "";
}
}
}
// URL base del CRM (Bucefalo) — los contactos abren en una pestaña nueva.
const BUCEFALO_CRM_BASE = "https://crm.bucefalocrm.io";
function buildContactCrmUrl(locationId, contactId) {
return `${BUCEFALO_CRM_BASE}/v2/location/${encodeURIComponent(locationId)}/contacts/detail/${encodeURIComponent(contactId)}`;
}
function escapeHtmlAttr(s) {
return String(s ?? "")
.replaceAll("&", "&")
.replaceAll('"', """)
.replaceAll("'", "'")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
async function loadContactsTable() {
const tbody = document.getElementById("contacts-table-rows");
syncWithoutOppToggle();
// El header "Acción" se decide tras conocer si hay contactos test en la pagina.
// Mostrar header si: (a) filtro sin-opp activo en sucursal (botón Crear opp),
// (b) hay al menos un contacto test en la pagina actual (botón Eliminar).
const withoutOppActionApplies = contactsWithoutOppFilter && activeBranchId !== "global";
const actionHeader = document.getElementById("contacts-action-header");
if (activeBranchId === "global") {
if (actionHeader) actionHeader.style.display = "none";
tbody.innerHTML = `
Visualización de Contactos por Sucursal
Para ver y filtrar contactos locales, selecciona una sucursal específica de Monte Providencia en la barra lateral izquierda.
`;
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 = `
`;
// Celda Acción: el botón Eliminar siempre está presente (toda fila se puede borrar
// bajo confirmación). El botón Crear oportunidad solo cuando el filtro sin-opp está activo.
const buttonsHTML = [];
if (withoutOppActionApplies) {
buttonsHTML.push(``);
}
const deleteTitle = c.is_test
? `Marcado como prueba: ${(c.test_reasons || []).join(" · ") || "—"}`
: "Eliminar contacto (definitivo, hace cascade a oportunidades)";
buttonsHTML.push(``);
const actionCellHTML = `
${buttonsHTML.join("")}
`;
// Fuente nativa de Bucéfalo (source). Si está vacía mostramos placeholder gris.
const sourceHTML = c.source
? `${escapeHtmlAttr(c.source)}`
: '—';
// Info de vehículo (custom fields del contacto). Placeholder gris si falta.
const vehCell = v => v ? escapeHtmlAttr(v) : '—';
const tr = document.createElement("tr");
if (c.is_test) tr.classList.add("contact-row-test");
tr.innerHTML = `
${nameCellHTML}
Error cargando contactos locales. Sincroniza la cuenta si está vacía.
`;
}
}
/**
* Elimina un contacto via endpoint DELETE /api/comparativa/contact (borra en
* Bucéfalo y limpia cache SQLite). La confirmación SIEMPRE pasa por el modal
* interno appConfirm — nada de confirm() nativo del navegador — para que las
* acciones queden registradas en el flujo de la plataforma.
*
* Funciona tanto para contactos de prueba (badge PRUEBA) como para contactos
* normales. El mensaje se adapta según `isTest`. GHL no permite undelete, así
* que es destructivo permanente.
*/
async function deleteContact(btn, { isTest = false } = {}) {
const locationId = btn.dataset.locationId;
const contactId = btn.dataset.contactId;
const contactName = btn.dataset.contactName || contactId;
const reasonsAttr = btn.dataset.testReasons || "";
const reasonsBlock = (isTest && reasonsAttr)
? `
Motivos detectados: ${reasonsAttr}
`
: "";
const intro = isTest
? `Vas a eliminar un contacto marcado como prueba/test:`
: `Vas a eliminar el contacto:`;
const ok = await appConfirm({
title: "Eliminar contacto",
severity: "danger",
message: `
${intro}
${contactName} ${contactId}
Esta acción borra el contacto en Bucéfalo de forma definitiva
(no hay rollback) y elimina sus oportunidades en cascada.
${reasonsBlock}
`,
confirmText: "Eliminar definitivamente",
cancelText: "Cancelar",
});
if (!ok) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` Eliminando...`;
try {
const url = `/api/comparativa/contact?contact_id=${encodeURIComponent(contactId)}&location_id=${encodeURIComponent(locationId)}`;
const response = await mutateFetch(url, { method: "DELETE" });
const data = await response.json();
if (!response.ok) {
const detail = (data && data.detail) || "No se pudo eliminar el contacto.";
showToast(`${contactName}: ${detail}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
return;
}
const oppsRemoved = data.local_opps_removed || 0;
showToast(
`Contacto "${contactName}" eliminado.${oppsRemoved > 0 ? ` (${oppsRemoved} oportunidades en cascada)` : ""}`,
"success"
);
// Sacar la fila de la tabla sin recargar todo.
const tr = btn.closest("tr");
if (tr) tr.remove();
// Refrescar contadores globales (badge sidebar, etc).
try { await loadBranches(); } catch (_) { /* tolerar */ }
} catch (err) {
showToast(`Error de red al eliminar el contacto: ${err.message || err}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
/**
* Crea una oportunidad en GHL para un contacto que no tiene ninguna.
* Llama al endpoint POST /api/contacts/{locationId}/{contactId}/opportunity.
* El backend valida pipeline 'Standar'/8/'En Pausa' y mapea custom fields
* (sucursal, tienda, fuente_prospecto, canal_origen, vehiculo) del contacto
* a la oportunidad. Si falta cualquiera de los 4 requeridos, aborta y se
* muestra el detalle en un toast de error.
*/
async function createOpportunityForContact(btn) {
const locationId = btn.dataset.locationId;
const contactId = btn.dataset.contactId;
const contactName = btn.dataset.contactName || contactId;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` Creando...`;
try {
const response = await mutateFetch(`/api/contacts/${encodeURIComponent(locationId)}/${encodeURIComponent(contactId)}/opportunity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const data = await response.json();
if (!response.ok) {
// El backend devuelve 409 con detail={ok:false, error, error_code} para
// errores de negocio (pipeline inválido, falta campo, contacto ya tiene opp).
const detail = data && data.detail;
const errMsg = (detail && typeof detail === "object" && detail.error)
? detail.error
: (typeof detail === "string" ? detail : "No se pudo crear la oportunidad.");
showToast(`${contactName}: ${errMsg}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
return;
}
const oppId = data.opportunity_id || "(sin id)";
showToast(`Oportunidad creada para ${contactName} en etapa '${data.stage_name}'. ID: ${oppId}`, "success");
// Refrescar la fila visualmente: se va de la tabla "sin oportunidad".
const tr = btn.closest("tr");
if (tr) tr.remove();
// Refrescar el contador del header/sidebar para reflejar la baja
// del badge "sin oportunidad" en esta sucursal.
try {
await loadBranches();
} catch (_) { /* tolerar */ }
// loadBranches refresca la sidebar, pero el botón bulk, el texto de
// status y los chips del header de la cuenta activa leen branchesData
// a través de otras funciones — refrescarlas explícitamente para que
// el flujo individual quede consistente con el flujo bulk.
syncWithoutOppToggle();
updateActiveInfoSummary();
} catch (err) {
showToast(`Error de red al crear la oportunidad: ${err.message || err}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
/**
* Bulk: crea oportunidades para TODOS los contactos sin oportunidad de la
* sucursal activa. Llama al endpoint POST /api/contacts/{loc}/bulk-create-opportunities
* que internamente itera y registra todo bajo un único run_id en script_audit.
*
* Confirmación SIEMPRE via appConfirm (modal interno) — nunca confirm() nativo.
* El backend puede tardar varios minutos para sucursales grandes; se muestra
* spinner en el botón y un toast con el resumen al terminar.
*/
async function bulkCreateOpportunities() {
if (activeBranchId === "global") return;
const btn = document.getElementById("btn-bulk-create-opps");
if (!btn) return;
const branch = branchesData.find(x => x.location_id === activeBranchId);
const branchName = branch?.nombre || activeBranchId;
const count = branch?.metrics?.contacts_without_opp_count ?? 0;
if (count === 0) {
showToast("No hay contactos sin oportunidad en esta sucursal.", "info");
return;
}
const ok = await appConfirm({
title: "Crear oportunidades en lote",
severity: "warning",
message: `
Vas a crear oportunidades para los ${count} contactos sin oportunidad
de la sucursal ${escapeHtmlAttr(branchName)}.
Cada oportunidad se crea con:
Estatus open y pipeline Standar (última etapa).
Custom fields copiados del contacto (sucursal, tienda, fuente, canal).
Si al contacto le faltan los 4 campos requeridos, esa fila se salta y se reporta.
Todos los cambios quedan registrados bajo un mismo run_id en auditoría.
Importante: a ${count} contactos esto puede tardar varios minutos por el rate limit de Bucéfalo.
`,
confirmText: `Crear las ${count} oportunidades`,
cancelText: "Cancelar",
});
if (!ok) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = ` Procesando ${count} contactos…`;
try {
const response = await mutateFetch(
`/api/contacts/${encodeURIComponent(activeBranchId)}/bulk-create-opportunities`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
);
const data = await response.json();
if (!response.ok) {
const detail = (data && data.detail) || "No se pudo procesar el lote.";
showToast(`Error en bulk: ${typeof detail === "string" ? detail : JSON.stringify(detail)}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
return;
}
const t = data.totals || {};
const runId = data.run_id || "(sin id)";
// Muestra resumen detallado en un modal interno para que el usuario
// pueda copiar el run_id y revisar saltados/fallidos antes de seguir.
const skippedList = (data.details || []).filter(d => !d.ok && (d.error_code === "missing_custom_fields" || d.error_code === "contact_has_opportunity")).slice(0, 10);
const failedList = (data.details || []).filter(d => !d.ok && d.error_code !== "missing_custom_fields" && d.error_code !== "contact_has_opportunity").slice(0, 10);
const renderList = (items, emptyMsg) => items.length
? `
`;
}
}
// --- 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 = `
`).join("");
}
}
}
function toggleComparativaBucket(uiKey) {
const body = document.getElementById(`comp-body-${uiKey}`);
if (!body) return;
const open = body.hasAttribute("hidden");
if (open) {
body.removeAttribute("hidden");
renderComparativaBucketBody(uiKey);
} else {
body.setAttribute("hidden", "");
}
// Cambiar icono del boton (toggle de chevron)
const btn = document.querySelector(`.btn-comparativa-toggle[data-bucket="${uiKey}"]`);
if (btn) {
btn.innerHTML = open
? ` Ocultar listado`
: ` Ver listado`;
}
}
// ---- Bulk selection para buckets de Comparativa ----
// Mantiene el conjunto de brand_contact_ids seleccionados por bucket.
const comparativaBulkSelection = {
// bucket_uiKey -> Set
};
function getBulkSelectionSet(uiKey) {
if (!comparativaBulkSelection[uiKey]) comparativaBulkSelection[uiKey] = new Set();
return comparativaBulkSelection[uiKey];
}
function clearBulkSelection(uiKey) {
comparativaBulkSelection[uiKey] = new Set();
}
function refreshBulkSelectionUI(uiKey) {
const body = document.getElementById(`comp-body-${uiKey}`);
if (!body) return;
const sel = getBulkSelectionSet(uiKey);
// Master checkbox (vive en el
de la primera columna): marcado si todas
// las filas visibles seleccionables están en la selección; indeterminado
// si hay marcadas parciales.
const master = body.querySelector(`.comp-bulk-master-checkbox[data-bucket="${uiKey}"]`);
const visibleSelectable = Array.from(body.querySelectorAll(`tbody tr`))
.filter(tr => tr.style.display !== "none")
.map(tr => tr.querySelector(`input.comp-bulk-row-checkbox:not([disabled])`))
.filter(Boolean);
if (master) {
if (visibleSelectable.length === 0) {
master.checked = false;
master.indeterminate = false;
} else {
const checkedCount = visibleSelectable.filter(cb => cb.checked).length;
master.checked = checkedCount === visibleSelectable.length;
master.indeterminate = checkedCount > 0 && checkedCount < visibleSelectable.length;
}
}
// Botón primario del header del bucket muestra el contador inline.
const headerBtn = document.getElementById("btn-sync-missing-in-assigned-branch");
if (headerBtn && uiKey === "missing_in_assigned_branch") {
if (sel.size === 0) {
headerBtn.disabled = true;
headerBtn.innerHTML = ` Crear seleccionados en sucursal`;
} else {
headerBtn.disabled = false;
headerBtn.innerHTML = ` Crear seleccionados en sucursal (${sel.size})`;
}
}
}
function bindBulkSelectionEvents(uiKey, body) {
if (body.dataset.bulkBound === "true") return;
body.dataset.bulkBound = "true";
body.addEventListener("change", (e) => {
const target = e.target;
if (!target) return;
// Checkbox por fila
if (target.matches(`input.comp-bulk-row-checkbox[data-bucket="${uiKey}"]`)) {
const id = target.dataset.brandContactId;
if (!id) return;
const sel = getBulkSelectionSet(uiKey);
if (target.checked) sel.add(id);
else sel.delete(id);
refreshBulkSelectionUI(uiKey);
}
// Master checkbox (vive en el
de la columna de seleccion)
if (target.matches(`input.comp-bulk-master-checkbox[data-bucket="${uiKey}"]`)) {
const checked = target.checked;
const sel = getBulkSelectionSet(uiKey);
const visibleRows = Array.from(body.querySelectorAll(`tbody tr`))
.filter(tr => tr.style.display !== "none");
visibleRows.forEach(tr => {
const cb = tr.querySelector(`input.comp-bulk-row-checkbox:not([disabled])`);
if (!cb) return;
cb.checked = checked;
const id = cb.dataset.brandContactId;
if (!id) return;
if (checked) sel.add(id);
else sel.delete(id);
});
refreshBulkSelectionUI(uiKey);
}
});
// Estado inicial.
refreshBulkSelectionUI(uiKey);
}
function renderComparativaBucketBody(uiKey) {
const body = document.getElementById(`comp-body-${uiKey}`);
if (!body) return;
if (body.dataset.rendered === "true") return;
const data = comparativaState.data;
if (!data) {
body.innerHTML = `
${cols.map(c => {
const thStyle = c.align ? ` style="text-align:${c.align}"` : "";
// Las columnas pueden definir headerRender(uiKey) para inyectar
// controles en el
`;
}
let v = item[c.key];
if (typeof c.format === "function") v = c.format(v);
if (v === undefined || v === null || v === "") v = "—";
const cell = escapeHtml(String(v));
return `
${cell}
`;
}).join("")}
`).join("")}
`;
// Para brand_without_tienda añadimos una sub-sección con los contactos detectados
// como prueba/test. El delete reutiliza el endpoint existente
// /api/comparativa/contact (mismo flujo que .btn-micro-delete[data-action=delete-brand-contact]).
let testSectionHtml = "";
if (uiKey === "brand_without_tienda") {
const testItems = bucket.items.filter(it => it.looks_like_test);
if (testItems.length > 0) {
testSectionHtml = `
Detectados como prueba/test
${testItems.length}
Contactos cuyo nombre, email o teléfono contiene test, testing, prueba o pruebas. Elimínalos si confirmas que no son leads reales.
Nombre
Teléfono
Email
Sucursal (CF)
${testItems.map(it => `
${contactLink(it.name, it.id, BRAND_LOCATION_ID_UI)} TEST
${escapeHtml(it.phone || "—")}
${escapeHtml(it.email || "—")}
${escapeHtml(it.sucursal || "—")}
`).join("")}
`;
}
}
// Depuracion defensiva del set de seleccion: si los datos se refrescaron
// (apply en otro bucket, reload manual), descarta IDs que ya no existen.
if (uiKey === "missing_in_assigned_branch") {
const sel = getBulkSelectionSet(uiKey);
if (sel.size) {
const stillExisting = new Set(bucket.items.map(it => it.id).filter(Boolean));
Array.from(sel).forEach(id => { if (!stillExisting.has(id)) sel.delete(id); });
}
}
body.innerHTML = `
${truncatedNote}
${mainTableHtml}
${testSectionHtml}
`;
const input = document.getElementById(filterId);
if (input) {
input.addEventListener("input", () => {
const term = input.value.trim().toLowerCase();
body.querySelectorAll("tbody tr").forEach(tr => {
tr.style.display = !term || tr.textContent.toLowerCase().includes(term) ? "" : "none";
});
// Si la fila se oculta, no entra como "visible" para el master-checkbox.
refreshBulkSelectionUI(uiKey);
});
}
// Listener delegado para botones de accion dentro del bucket body.
// Solo bindeo una vez por body (dataset.actionsBound).
if (body.dataset.actionsBound !== "true") {
body.addEventListener("click", handleComparativaBucketAction);
body.dataset.actionsBound = "true";
}
// Bindeo de bulk-selection solo para los buckets que la soportan.
if (uiKey === "missing_in_assigned_branch") {
bindBulkSelectionEvents(uiKey, body);
// Re-render: sincroniza UI con el set en memoria (contador, master, botón header).
refreshBulkSelectionUI(uiKey);
}
body.dataset.rendered = "true";
}
function updateIntraBrandDuplicateGroup(body, nameNorm) {
if (!body || !nameNorm) return;
const escSel = window.CSS && CSS.escape ? CSS.escape(nameNorm) : nameNorm.replace(/"/g, '\\"');
const remainingBtns = body.querySelectorAll(`.btn-micro-delete[data-name-norm="${escSel}"]`);
const trsInGroup = [];
remainingBtns.forEach(b => { const tr = b.closest("tr"); if (tr) trsInGroup.push(tr); });
const count = trsInGroup.length;
trsInGroup.forEach(tr => {
const groupSpan = tr.querySelector(".group-count");
const btn = tr.querySelector(".btn-micro-delete");
const syncBtn = tr.querySelector(".btn-micro-sync-from-branch");
if (count <= 1) {
if (groupSpan) {
groupSpan.textContent = "Único restante";
groupSpan.classList.add("group-count--last");
}
if (btn) {
btn.disabled = true;
btn.title = "Es el único restante del grupo — ya no es un duplicado";
btn.classList.add("is-locked");
}
// Si hay candidatos sucursal, revelar el sync btn.
if (syncBtn) {
syncBtn.hidden = false;
syncBtn.title = "Sincronizar desde sucursal: actualiza este contacto Marca con todos los datos y custom fields del contacto sucursal con mismo nombre.";
}
} else {
if (groupSpan) {
groupSpan.textContent = `${count} con mismo nombre`;
groupSpan.classList.remove("group-count--last");
}
// Si vuelve a haber más, ocultar el sync btn.
if (syncBtn) syncBtn.hidden = true;
}
});
}
async function handleUpdateBranchFromBrandClick(btn) {
const brandContactId = btn.dataset.brandContactId;
const branchContactId = btn.dataset.branchContactId;
const branchLocationId = btn.dataset.branchLocationId;
const branchName = btn.dataset.branchName || "(sucursal)";
const name = btn.dataset.name || "(sin nombre)";
const ok = await appConfirm({
title: "Actualizar contacto en sucursal",
severity: "warning",
message: `Vas a actualizar el contacto ${escapeHtmlAttr(name)} que está en ${escapeHtmlAttr(branchName)} con los datos y custom fields del contacto Marca.
Esto pisa los campos del contacto en sucursal con los del Marca.`,
confirmText: "Actualizar",
});
if (!ok) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '';
try {
const res = await mutateFetch("/api/comparativa/update-branch-from-brand", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand_contact_id: brandContactId,
branch_contact_id: branchContactId,
branch_location_id: branchLocationId,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
btn.innerHTML = '';
btn.title = `Actualizado: ${data.custom_fields_count} custom fields sincronizados.`;
btn.classList.add("is-done");
if (typeof comparativaState !== "undefined") comparativaState.data = null;
} catch (err) {
showToast(`Error: ${err.message}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
/**
* Verifica si el contacto Marca tiene un homónimo EXACTO en la sucursal esperada.
* Read-only: solo consulta SQLite. Si match, refresca la tabla (el contacto
* debería desaparecer del bucket brand_not_in_any_branch automáticamente).
*/
async function handleVerifyInExpectedBranchClick(btn) {
const brandContactId = btn.dataset.brandContactId;
const expectedLocationId = btn.dataset.expectedLocationId;
const expectedBranchName = btn.dataset.expectedBranchName || expectedLocationId;
const name = btn.dataset.name || "(sin nombre)";
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '';
try {
// Verify es read-only — usamos fetch directo (no necesita modo dry-run/live).
const res = await fetch("/api/comparativa/verify-in-expected-branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand_contact_id: brandContactId, expected_location_id: expectedLocationId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
if (data.found) {
const matchesHtml = (data.matches || []).map(m =>
`
`
).join("");
await appConfirm({
title: "Encontrado en la sucursal esperada",
severity: "info",
message: `
Se encontraron ${data.match_count} match(es) EXACTO(s) para
${escapeHtml(name)} en ${escapeHtml(expectedBranchName)}:
${matchesHtml}
Esto significa que el audit Marca↔Sucursal probablemente no lo cruzó por phone/email distintos.
Al refrescar la comparativa, este contacto debería salir del bucket "sin contraparte".
`,
confirmText: "Refrescar comparativa",
cancelText: "Cerrar",
}).then(refresh => {
if (refresh && typeof loadComparativaMarcaSucursales === "function") {
if (typeof comparativaState !== "undefined") comparativaState.data = null;
loadComparativaMarcaSucursales(true);
}
});
btn.innerHTML = '';
btn.title = `Encontrado en ${expectedBranchName}.`;
btn.classList.add("is-done");
} else {
await appConfirm({
title: "Sin homónimo exacto",
severity: "warning",
message: `
No se encontró un contacto con nombre EXACTO "${escapeHtml(name)}" en
${escapeHtml(expectedBranchName)}.
Posibles razones:
El contacto efectivamente no existe en la sucursal.
El nombre difiere por mayúsculas, acentos o espacios (verify exige 100% byte por byte tras normalizar).
SQLite no está sincronizado con la sucursal — corre "Sincronizar Sucursal" y reintenta.
`,
confirmText: "Entendido",
cancelText: "Cerrar",
});
btn.disabled = false;
btn.innerHTML = originalHTML;
}
} catch (err) {
showToast(`Error al verificar: ${err.message}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
/**
* Crea el contacto Marca en la sucursal esperada (POST contact con CFs mapeados).
* Respeta el toggle global Dry-Run/Live via mutateFetch. Tras crear, refresca
* la comparativa para que el contacto salga del bucket.
*/
/**
* Click en "Llenar" (botón por-item) del bucket "opps_missing_id_field".
* Abre el modal genérico de sync acotando a esa única opp para validar y aplicar.
*/
async function handleFillOppIdClick(btn) {
const oppId = btn.dataset.oppId;
if (!oppId) return;
openSyncBucketModal("opps_missing_id_field", { contactIds: [oppId] });
}
/**
* Click en "Matchear" (botón por-item para una opp Marca) del bucket
* "opps_missing_id_field". Abre el modal genérico apuntando al bucket sintético
* que rutea al endpoint /api/comparativa/match-brand-opp-id-sucursal.
*/
async function handleMatchBrandOppIdClick(btn) {
const oppId = btn.dataset.oppId;
if (!oppId) return;
openSyncBucketModal("opps_missing_id_field_brand", { contactIds: [oppId] });
}
/**
* Click en "Llenar" (botón por-item) del bucket "contacts_missing_id_field".
*/
async function handleFillContactIdClick(btn) {
const contactId = btn.dataset.contactId;
if (!contactId) return;
openSyncBucketModal("contacts_missing_id_field", { contactIds: [contactId] });
}
async function handleCreateInExpectedBranchClick(btn) {
const brandContactId = btn.dataset.brandContactId;
const expectedLocationId = btn.dataset.expectedLocationId;
const expectedBranchName = btn.dataset.expectedBranchName || expectedLocationId;
const name = btn.dataset.name || "(sin nombre)";
const isLive = window.AppMode.isLive;
const ok = await appConfirm({
title: isLive ? "Crear contacto en sucursal (LIVE)" : "Crear contacto en sucursal (Simulación)",
severity: isLive ? "danger" : "info",
message: `
Vas a crear el contacto ${escapeHtml(name)} en la sucursal
${escapeHtml(expectedBranchName)} (${escapeHtml(expectedLocationId)}).
Se copian first/last name, email, phone y todos los custom fields mapeados por nombre
desde el schema Marca al schema de la sucursal.
${isLive
? "Atención: estás en modo LIVE. Se creará de verdad en Bucéfalo. Si ya existe homónimo por email/teléfono, GHL rechazará la creación."
: "Estás en modo Simulación. Confirmar solo cierra este preview — nada se aplicará. Cambia a Live para crear de verdad."
}
`,
confirmText: isLive ? "Sí, crear en sucursal" : "Entendido (no aplica)",
cancelText: "Cancelar",
});
if (!ok) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '';
try {
const res = await mutateFetch("/api/comparativa/create-in-expected-branch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand_contact_id: brandContactId, expected_location_id: expectedLocationId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(typeof err.detail === "string" ? err.detail : JSON.stringify(err.detail));
}
const data = await res.json();
if (data.dry_run) {
showToast(`Modo Simulación: se crearía "${name}" en ${expectedBranchName}.`, "info");
btn.disabled = false;
btn.innerHTML = originalHTML;
return;
}
showToast(`Contacto "${name}" creado en ${expectedBranchName}. ID: ${data.branch_contact_id}. run_id=${data.run_id || "—"}`, "success");
btn.innerHTML = '';
btn.classList.add("is-done");
if (typeof comparativaState !== "undefined") comparativaState.data = null;
if (typeof loadComparativaMarcaSucursales === "function") {
loadComparativaMarcaSucursales(true);
}
} catch (err) {
showToast(`Error al crear en sucursal: ${err.message}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
async function handleUpdateBrandTiendaClick(btn) {
const brandContactId = btn.dataset.brandContactId;
const name = btn.dataset.name || "(sin nombre)";
let branches = [];
try { branches = JSON.parse(btn.dataset.branches || "[]"); } catch (e) { branches = []; }
if (!branches.length) { showToast("No hay sucursales destino para usar.", "error"); return; }
let chosen = branches[0];
if (branches.length > 1) {
const idx = await appSelectFromList({
title: "Elige la sucursal de referencia",
message: `${escapeHtmlAttr(name)} aparece en ${branches.length} sucursales. Selecciona aquella cuya TIENDA se aplicará al contacto Marca:`,
options: branches.map(b => ({
label: b.name,
sublabel: `TIENDA: ${b.tienda_value || "—"}`,
})),
confirmText: "Usar esta sucursal",
});
if (idx === null) return;
chosen = branches[idx];
}
if (!chosen.tienda_value) {
showToast(`La sucursal ${chosen.name} no tiene un valor TIENDA en el verificador. No se puede actualizar.`, "error");
return;
}
const ok = await appConfirm({
title: "Cambiar TIENDA del contacto Marca",
severity: "warning",
message: `Vas a cambiar el valor TIENDA del contacto Marca ${escapeHtmlAttr(name)} a ${escapeHtmlAttr(chosen.tienda_value)} (sucursal ${escapeHtmlAttr(chosen.name)}).
Esto corrige el mapeo del contacto a la sucursal donde realmente está.`,
confirmText: "Actualizar TIENDA",
});
if (!ok) return;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '';
try {
const res = await mutateFetch("/api/comparativa/update-brand-tienda", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand_contact_id: brandContactId,
new_tienda_value: chosen.tienda_value,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
await res.json();
btn.innerHTML = '';
btn.title = `TIENDA actualizada a "${chosen.tienda_value}".`;
btn.classList.add("is-done");
if (typeof comparativaState !== "undefined") comparativaState.data = null;
} catch (err) {
showToast(`Error: ${err.message}`, "error");
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
async function handleSyncFromBranchClick(syncBtn) {
const brandContactId = syncBtn.dataset.brandContactId;
const name = syncBtn.dataset.name || "(sin nombre)";
let candidates = [];
try {
candidates = JSON.parse(syncBtn.dataset.branchCandidates || "[]");
} catch (e) { candidates = []; }
if (!candidates.length) {
showToast("No hay candidatos en sucursal para sincronizar.", "error");
return;
}
let chosen = candidates[0];
if (candidates.length > 1) {
const idx = await appSelectFromList({
title: "Elige el contacto en sucursal",
message: `Hay ${candidates.length} candidatos en sucursal para ${escapeHtmlAttr(name)}. Selecciona cuál se usará como fuente:`,
options: candidates.map(c => ({
label: c.branch_name,
sublabel: `${c.opps_count} oportunidad(es)`,
})),
confirmText: "Usar este contacto",
});
if (idx === null) return;
chosen = candidates[idx];
}
const ok = await appConfirm({
title: "Sincronizar contacto Marca desde sucursal",
severity: "warning",
message: `Vas a actualizar el contacto Marca ${escapeHtmlAttr(name)} con los datos y custom fields del contacto en ${escapeHtmlAttr(chosen.branch_name)}.
Esto pisa los campos del contacto Marca con los de sucursal (los CFs en Marca que no existen en sucursal se conservan).`,
confirmText: "Sincronizar",
});
if (!ok) return;
const originalHTML = syncBtn.innerHTML;
syncBtn.disabled = true;
syncBtn.innerHTML = '';
try {
const res = await mutateFetch("/api/comparativa/sync-from-branch-contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
brand_contact_id: brandContactId,
branch_contact_id: chosen.id,
branch_location_id: chosen.location_id,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
syncBtn.innerHTML = '';
syncBtn.title = `Sincronizado desde ${chosen.branch_name} (${data.custom_fields_count} custom fields actualizados).`;
syncBtn.classList.add("is-done");
if (typeof comparativaState !== "undefined") comparativaState.data = null;
} catch (err) {
showToast(`Error: ${err.message}`, "error");
syncBtn.disabled = false;
syncBtn.innerHTML = originalHTML;
}
}
async function handleComparativaBucketAction(e) {
const syncBtn = e.target.closest(".btn-micro-sync-from-branch");
if (syncBtn && syncBtn.dataset.action === "sync-from-branch") {
await handleSyncFromBranchClick(syncBtn);
return;
}
if (syncBtn && syncBtn.dataset.action === "update-branch-from-brand") {
await handleUpdateBranchFromBrandClick(syncBtn);
return;
}
if (syncBtn && syncBtn.dataset.action === "update-brand-tienda") {
await handleUpdateBrandTiendaClick(syncBtn);
return;
}
// Botones nuevos del bucket brand_not_in_any_branch.
const verifyBtn = e.target.closest(".btn-micro-verify");
if (verifyBtn && verifyBtn.dataset.action === "verify-in-expected-branch") {
await handleVerifyInExpectedBranchClick(verifyBtn);
return;
}
const createInBranchBtn = e.target.closest(".btn-micro-create-in-branch");
if (createInBranchBtn && createInBranchBtn.dataset.action === "create-in-expected-branch") {
await handleCreateInExpectedBranchClick(createInBranchBtn);
return;
}
const fillOppIdBtn = e.target.closest(".btn-micro-fill-opp-id");
if (fillOppIdBtn && fillOppIdBtn.dataset.action === "fill-opp-id") {
await handleFillOppIdClick(fillOppIdBtn);
return;
}
const matchBrandOppIdBtn = e.target.closest(".btn-micro-match-brand-opp-id");
if (matchBrandOppIdBtn && matchBrandOppIdBtn.dataset.action === "match-brand-opp-id") {
await handleMatchBrandOppIdClick(matchBrandOppIdBtn);
return;
}
const fillContactIdBtn = e.target.closest(".btn-micro-fill-contact-id");
if (fillContactIdBtn && fillContactIdBtn.dataset.action === "fill-contact-id") {
await handleFillContactIdClick(fillContactIdBtn);
return;
}
const delBtn = e.target.closest(".btn-micro-delete");
if (delBtn && delBtn.dataset.action === "delete-brand-contact") {
const contactId = delBtn.dataset.contactId;
const name = delBtn.dataset.name || "(sin nombre)";
const opps = parseInt(delBtn.dataset.opps, 10) || 0;
const oppsClause = opps > 0
? `Tiene ${opps} oportunidad${opps !== 1 ? "es" : ""} asociada${opps !== 1 ? "s" : ""} que también será${opps !== 1 ? "n" : ""} eliminada${opps !== 1 ? "s" : ""}.
`
: "";
const ok = await appConfirm({
title: "Eliminar contacto Marca",
severity: "danger",
message: `¿Eliminar ${escapeHtmlAttr(name)} del CRM de Marca?
${oppsClause}Esta acción es IRREVERSIBLE.`,
confirmText: "Eliminar definitivamente",
});
if (!ok) return;
const originalHTML = delBtn.innerHTML;
delBtn.disabled = true;
delBtn.innerHTML = '';
try {
const url = `/api/comparativa/contact?contact_id=${encodeURIComponent(contactId)}&location_id=GbKkBpCmKu2QmloKFHy3`;
const res = await mutateFetch(url, { method: "DELETE" });
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
// Remover fila visualmente
const tr = delBtn.closest("tr");
const containerBody = tr ? tr.closest(".comparativa-bucket-body") : null;
const nameNorm = delBtn.dataset.nameNorm || "";
if (tr) tr.remove();
// Actualizar badge del bucket al que pertenece la fila eliminada (-1).
// El bucket se identifica por el id del body (ej. "comp-body-intra_brand_duplicates").
if (containerBody && containerBody.id) {
const bucketKey = containerBody.id.replace(/^comp-body-/, "");
const badge = document.getElementById(`comp-bucket-${bucketKey}`);
if (badge) {
const n = parseInt(badge.textContent.replace(/\D/g, ""), 10) || 0;
if (n > 0) badge.textContent = (n - 1).toLocaleString("es-MX");
}
// Si es el bucket de duplicados, actualizar contadores del grupo
// y deshabilitar el delete del ultimo restante.
if (bucketKey === "intra_brand_duplicates" && nameNorm) {
updateIntraBrandDuplicateGroup(containerBody, nameNorm);
}
}
// Invalidar cache del state para que el próximo "Recalcular" pida data fresh.
if (typeof comparativaState !== "undefined") comparativaState.data = null;
} catch (err) {
showToast(`Error al eliminar: ${err.message}`, "error");
delBtn.disabled = false;
delBtn.innerHTML = originalHTML;
}
}
}
function exportComparativaBucket(uiKey) {
window.open(`/api/comparativa/marca-vs-sucursales/export?bucket=${encodeURIComponent(uiKey)}`, "_blank");
}
function comparativaSetText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
// ==========================================================================
// SYNC TO BRAND (boton + modal generico para distintos buckets)
// ==========================================================================
const SYNC_BUCKETS_CONFIG = {
"missing_opps_in_brand": {
triggerBtnId: "btn-sync-missing-opps",
endpoint: "/api/comparativa/sync-missing-opps",
modalTitleIcon: "fa-rotate",
bucketTitle: "opps faltantes en Marca",
applyNoteHtml: "Escribiendo en GHL. Esto puede demorar (10s por cada contacto nuevo creado).",
emptyMsg: "No hay oportunidades pendientes de sincronizar a Marca. ¡Todo limpio!",
summaryFields: (s, isApplyResult) => [
{ label: "Candidatas", value: s.candidates },
{ label: isApplyResult ? "Contactos creados" : "Contactos a crear", value: s.contacts_created },
{ label: isApplyResult ? "Creadas (opps)" : "A crear (opps)", value: s.opps_created },
{ label: isApplyResult ? "Actualizadas (opps)" : "A actualizar (opps)", value: s.opps_updated },
{ label: "Errores", value: s.errors, danger: !!s.errors },
],
columns: ["Acción", "Oportunidad", "Sucursal", "Contacto", "Operaciones"],
renderRow: (it, isApplyResult) => {
const statusClass = it.status === "error" ? "is-danger" :
it.status === "created" ? "is-create" :
it.status === "updated" ? "is-update" : "";
const statusLabel = {
"created": isApplyResult ? "Creada" : "Crear",
"updated": isApplyResult ? "Actualizada" : "Actualizar",
"error": "Error",
"pending": "Pendiente",
}[it.status] || it.status;
const contactName = (it.branch_contact && it.branch_contact.name) || "(sin contacto)";
const contactRef = (it.branch_contact && (it.branch_contact.phone || it.branch_contact.email)) || "";
const errorHTML = it.error ? `
`;
},
},
"brand_duplicate_link_opps": {
triggerBtnId: "btn-cleanup-duplicate-opps",
endpoint: "/api/comparativa/cleanup-duplicate-opps",
modalTitleIcon: "fa-broom",
bucketTitle: "réplicas duplicadas en Marca",
applyNoteHtml: "Eliminando en GHL las réplicas sobrantes (conserva la canónica). El DELETE no es reversible en GHL, pero queda snapshot + run_id para auditoría/recreación.",
emptyMsg: "No hay réplicas duplicadas en Marca. ¡Todo limpio!",
hasWorkToDo: (s) => (s.extra_opps || 0) > 0,
summaryFields: (s, isApplyResult) => [
{ label: "Clusters (mismo ID Opp Sucursal)", value: s.clusters || 0 },
{ label: isApplyResult ? "Réplicas borradas" : "Réplicas a borrar", value: isApplyResult ? (s.deleted || 0) : (s.extra_opps || 0) },
{ label: "Clusters resueltos en vivo", value: s.ok || 0 },
{ label: "Omitidos (ya sin duplicado)", value: s.skipped || 0 },
{ label: "Errores", value: s.errors || 0, danger: !!s.errors },
],
columns: ["Acción", "Oportunidad", "Conservar", "Borrar", "ID Opp Sucursal"],
renderRow: (it, isApplyResult) => {
const k = it.keep || {};
const dels = it.delete || [];
const statusClass = isApplyResult ? "is-danger" : "is-skip";
const statusLabel = isApplyResult ? "Borrada" : "Borrar";
const keepHTML = `${escapeHtml(k.id || "")} ${escapeHtml(k.status || "")} · $${Number(k.monetaryValue || 0).toLocaleString("es-MX")} · ${escapeHtml((k.createdAt || "").slice(0, 10))}`;
const delHTML = dels.map(d => `${escapeHtml(d.id || "")}(${escapeHtml((d.createdAt || "").slice(0, 10))})`).join(" ");
return `
${statusLabel}
${escapeHtml(it.name || "(sin nombre)")}
${keepHTML}
${delHTML}
${escapeHtml(it.link_value || "")}
`;
},
},
};
let syncBucketState = {
bound: false,
activeBucket: null,
planResult: null,
loading: false,
contactIds: null, // null = todos los del bucket; array = subset seleccionado por el usuario.
};
function bindSyncBucketEvents() {
if (syncBucketState.bound) return;
Object.entries(SYNC_BUCKETS_CONFIG).forEach(([bucketKey, cfg]) => {
const btn = document.getElementById(cfg.triggerBtnId);
if (!btn) return;
btn.addEventListener("click", () => {
// Buckets con seleccion en bulk: pasamos los IDs marcados.
if (bucketKey === "missing_in_assigned_branch") {
const sel = (typeof getBulkSelectionSet === "function")
? getBulkSelectionSet("missing_in_assigned_branch")
: new Set();
if (!sel.size) {
if (typeof showToast === "function") {
showToast("Selecciona al menos un contacto del listado para crear en sucursal.", "info");
}
return;
}
openSyncBucketModal(bucketKey, { contactIds: Array.from(sel) });
return;
}
// contacts_missing_id_field: el llenado SOLO aplica a sucursales
// (Marca se resuelve por matcheo/sync). Filtramos los items.
if (bucketKey === "contacts_missing_id_field") {
const items = (comparativaState && comparativaState.data
&& comparativaState.data.missing
&& comparativaState.data.missing.contacts_missing_id_field
&& comparativaState.data.missing.contacts_missing_id_field.items) || [];
const branchContactIds = items.filter(it => !it.is_brand).map(it => it.id).filter(Boolean);
if (!branchContactIds.length) {
if (typeof showToast === "function") {
showToast("No hay contactos de sucursal con el campo vacío o inválido.", "info");
}
return;
}
openSyncBucketModal(bucketKey, { contactIds: branchContactIds });
return;
}
// opps_missing_id_field: el llenado SOLO aplica a sucursales (Marca
// se resuelve por matcheo/sync). Filtramos del bucket actual.
if (bucketKey === "opps_missing_id_field") {
const items = (comparativaState && comparativaState.data
&& comparativaState.data.missing
&& comparativaState.data.missing.opportunities_missing_id_field
&& comparativaState.data.missing.opportunities_missing_id_field.items) || [];
const branchOppIds = items.filter(it => !it.is_brand).map(it => it.id).filter(Boolean);
if (!branchOppIds.length) {
if (typeof showToast === "function") {
showToast("No hay opps de sucursal con el campo vacío o inválido. (Las de Marca se resuelven por matcheo/sync.)", "info");
}
return;
}
openSyncBucketModal(bucketKey, { contactIds: branchOppIds });
return;
}
// opps_missing_id_field_brand: bucket sintético que apunta al endpoint
// de matching Marca↔sucursal. Filtra las filas Marca del bucket original.
if (bucketKey === "opps_missing_id_field_brand") {
const items = (comparativaState && comparativaState.data
&& comparativaState.data.missing
&& comparativaState.data.missing.opportunities_missing_id_field
&& comparativaState.data.missing.opportunities_missing_id_field.items) || [];
const brandOppIds = items.filter(it => it.is_brand).map(it => it.id).filter(Boolean);
if (!brandOppIds.length) {
if (typeof showToast === "function") {
showToast("No hay opps de Marca con el campo vacío o inválido para matchear.", "info");
}
return;
}
openSyncBucketModal(bucketKey, { contactIds: brandOppIds });
return;
}
openSyncBucketModal(bucketKey);
});
});
const btnClose = document.getElementById("sync-modal-close");
const btnCancel = document.getElementById("sync-modal-cancel");
const btnApply = document.getElementById("sync-modal-apply");
const overlay = document.getElementById("sync-missing-opps-modal");
if (btnClose) btnClose.addEventListener("click", closeSyncBucketModal);
if (btnCancel) btnCancel.addEventListener("click", closeSyncBucketModal);
if (btnApply) btnApply.addEventListener("click", applySyncBucket);
if (overlay) {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) closeSyncBucketModal();
});
}
syncBucketState.bound = true;
}
function openSyncBucketModal(bucketKey, opts) {
bindSyncBucketEvents();
syncBucketState.activeBucket = bucketKey;
syncBucketState.contactIds = (opts && Array.isArray(opts.contactIds) && opts.contactIds.length)
? opts.contactIds.slice()
: null;
const modal = document.getElementById("sync-missing-opps-modal");
if (!modal) return;
modal.hidden = false;
modal.classList.add("is-open");
runSyncBucketDryRun();
}
function closeSyncBucketModal() {
const modal = document.getElementById("sync-missing-opps-modal");
if (!modal) return;
modal.hidden = true;
modal.classList.remove("is-open");
syncBucketState.planResult = null;
syncBucketState.activeBucket = null;
syncBucketState.contactIds = null;
const apply = document.getElementById("sync-modal-apply");
if (apply) apply.hidden = true;
// Restaurar texto del boton por si la proxima apertura arranca en modo plan.
const cancel = document.getElementById("sync-modal-cancel");
if (cancel) cancel.innerHTML = "Cancelar";
}
async function runSyncBucketDryRun() {
const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket];
if (!cfg) return;
const body = document.getElementById("sync-modal-body");
const apply = document.getElementById("sync-modal-apply");
if (body) body.innerHTML = `
Calculando plan... esto consulta el audit completo, puede tomar unos segundos.
`;
if (apply) apply.hidden = true;
syncBucketState.loading = true;
try {
// Preview/plan SIEMPRE es dry_run=true (sin afectar al toggle global).
const bodyPayload = { dry_run: true };
const idsField = cfg.bodyIdsField || "contact_ids";
if (Array.isArray(syncBucketState.contactIds) && syncBucketState.contactIds.length) {
bodyPayload[idsField] = syncBucketState.contactIds;
}
const res = await mutateFetch(cfg.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bodyPayload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
syncBucketState.planResult = data;
renderSyncBucketPlan(data, /*isApplyResult*/ false);
// Solo mostramos Aplicar si hay candidatos Y no son todos skips (skips no requieren apply).
// El bucket puede definir su propia función hasWorkToDo(summary); si no, usa el default.
const summary = data.summary || {};
const hasWorkToDo = typeof cfg.hasWorkToDo === "function"
? cfg.hasWorkToDo(summary)
: (summary.candidates > 0 &&
((summary.contacts_created || 0) > 0 ||
(summary.contacts_updated || 0) > 0 ||
(summary.opps_created || 0) > 0 ||
(summary.opps_updated || 0) > 0));
if (apply && hasWorkToDo) apply.hidden = false;
} catch (e) {
if (body) body.innerHTML = `
Error: ${escapeHtml(e.message)}
`;
} finally {
syncBucketState.loading = false;
}
}
async function applySyncBucket() {
const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket];
if (!cfg || syncBucketState.loading) return;
const apply = document.getElementById("sync-modal-apply");
const cancel = document.getElementById("sync-modal-cancel");
const body = document.getElementById("sync-modal-body");
if (apply) {
apply.disabled = true;
apply.innerHTML = ` Aplicando en GHL...`;
}
if (cancel) cancel.disabled = true;
if (body) {
const note = document.createElement("p");
note.className = "text-secondary";
note.style.marginTop = "12px";
note.innerHTML = ` ${cfg.applyNoteHtml}`;
body.prepend(note);
}
syncBucketState.loading = true;
try {
// El "aplicar" respeta el modo global: en dry-run, fuerza dry_run=true
// en el body para que el endpoint legacy no escriba en Bucéfalo. El
// header X-Dry-Run también va, por si el endpoint usa el helper nuevo.
const respectGlobalMode = window.AppMode.isDryRun;
const bodyPayload = { dry_run: respectGlobalMode, yes: true };
const idsField = cfg.bodyIdsField || "contact_ids";
if (Array.isArray(syncBucketState.contactIds) && syncBucketState.contactIds.length) {
bodyPayload[idsField] = syncBucketState.contactIds;
}
const res = await mutateFetch(cfg.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bodyPayload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
renderSyncBucketPlan(data, /*isApplyResult*/ true);
if (apply) apply.hidden = true;
// Tras un apply exitoso de un bucket con seleccion en bulk, limpia el
// set guardado en memoria para que el siguiente render del bucket
// arranque desde cero (los IDs procesados ya no son candidatos).
const bucketKey = syncBucketState.activeBucket;
if (bucketKey === "missing_in_assigned_branch" && typeof clearBulkSelection === "function") {
clearBulkSelection(bucketKey);
}
comparativaState.data = null;
loadComparativaMarcaSucursales(true);
} catch (e) {
if (body) {
const errEl = document.createElement("p");
errEl.className = "text-danger";
errEl.innerHTML = ` Error: ${escapeHtml(e.message)}`;
body.prepend(errEl);
}
} finally {
syncBucketState.loading = false;
if (apply) {
apply.disabled = false;
apply.innerHTML = ` Aplicar cambios en GHL`;
}
if (cancel) cancel.disabled = false;
}
}
function renderSyncBucketPlan(data, isApplyResult) {
const cfg = SYNC_BUCKETS_CONFIG[syncBucketState.activeBucket];
if (!cfg) return;
const body = document.getElementById("sync-modal-body");
const title = document.getElementById("sync-modal-title");
if (!body) return;
const s = data.summary || {};
const items = data.items || [];
if (title) {
title.innerHTML = isApplyResult
? ` Resultado de la sincronización · ${cfg.bucketTitle}`
: ` Plan de sincronización · ${cfg.bucketTitle}`;
}
// Tras aplicar los cambios ya no tiene sentido "Cancelar" (los cambios
// estan en GHL y el rollback se hace por run_id desde Scripts y Auditorias).
// Cambiamos el texto a "Cerrar" para que el boton refleje lo que realmente hace.
const cancelBtn = document.getElementById("sync-modal-cancel");
if (cancelBtn) {
cancelBtn.innerHTML = isApplyResult
? ` Cerrar`
: `Cancelar`;
}
if (!s.candidates) {
body.innerHTML = `