/* ════════════════════════════════ INFINITY — ADMIN.JS Sistema escalable de permisos por pestañas ════════════════════════════════ */ // ════════════════════════════════ // TABS — fuente de verdad de permisos // Para agregar una pestaña nueva: agrégala aquí y al HTML con data-view // ════════════════════════════════ const TABS = [ { id: 'productos', label: 'Productos', icon: '📦' }, { id: 'agotados', label: 'Agotados', icon: '🚫' }, { id: 'descargas', label: 'Descargas', icon: '⬇️' }, { id: 'categorias', label: 'Categorías', icon: '🗂️' }, { id: 'usuarios', label: 'Usuarios', icon: '👥' }, { id: 'pedidos', label: 'Pedidos', icon: '📋' } ]; const ROLES = { admin: 'Administrador', editor: 'Editor', vendedor: 'Vendedor' }; // Iconos disponibles para categorías const CAT_ICONS = [ '💄','👜','☕','🏠','👓','🧴','🕯️','🖼️', '💍','🧣','👒','🪞','🎁','🪴','🍵','📿', '🧸','🛍️','👗','📦','🎀','🕊️','✨','🌸' ]; let currentUser = null; // { usuario, rol, permisos: [] } let editingUser = null; // nombre del usuario que se está editando let deletingUser = null; // nombre del usuario a eliminar let editingCategoria = null; // id de la categoría que se está editando let deletingCategoria = null; // id de la categoría que se va a eliminar let currentView = 'dashboard'; let cachedProductos = []; // cache de productos para contar items por categoría // ════════════════════════════════ // INIT // ════════════════════════════════ document.addEventListener('DOMContentLoaded', async () => { setupLoginForm(); setupLogout(); setupNavigation(); setupSubtabs(); setupModals(); setupPermisosToggles(); setupCategoriasModals(); setupProductosUpload(); setupProductosSearch(); setupExcelUpload(); await checkSession(); }); // ════════════════════════════════ // SESIÓN // ════════════════════════════════ async function checkSession() { try { const res = await fetch('/api/auth/me', { credentials: 'include' }); if (res.ok) { const user = await res.json(); currentUser = user; mostrarDashboard(user); } else { mostrarLogin(); } } catch (err) { console.error('Error verificando sesión:', err); mostrarLogin(); } } // ════════════════════════════════ // VISTAS // ════════════════════════════════ function mostrarLogin() { currentUser = null; document.body.classList.remove('is-admin'); document.getElementById('loginView').style.display = 'flex'; document.getElementById('adminApp').style.display = 'none'; } function mostrarDashboard(user) { currentUser = user; document.getElementById('loginView').style.display = 'none'; document.getElementById('adminApp').style.display = 'block'; // Marcar el body según el rol document.body.classList.toggle('is-admin', user.rol === 'admin'); // Saludo con el nombre del usuario document.getElementById('adminName').textContent = user.usuario; // Aplicar permisos (visibilidad de nav, subsección admin-only, etc.) aplicarPermisos(); // Determinar vista inicial const esAdmin = user.rol === 'admin'; const perms = getPermisosEfectivos(); let viewInicial; if (esAdmin || perms.includes('dashboard')) { viewInicial = 'dashboard'; } else { // Primera vista visible según permisos viewInicial = perms[0] || 'descargas'; } cambiarVista(viewInicial); cargarEstadisticas(); } function cambiarVista(view) { // Check defensivo: si el usuario no tiene permiso para esta vista, redirige a la primera vista visible if (currentUser) { const esAdmin = currentUser.rol === 'admin'; const perms = getPermisosEfectivos(); if (!esAdmin && !perms.includes(view)) { view = perms[0] || 'descargas'; } } currentView = view; // Ocultar todas las vistas document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); // Mostrar la vista solicitada (si existe) const target = document.querySelector(`.view[data-view="${view}"]`); if (target) { target.classList.add('active'); } else { document.querySelector('.view[data-view="dashboard"]').classList.add('active'); view = 'dashboard'; } // Actualizar nav items (excluir el de logout que no tiene data-view) document.querySelectorAll('.nav-item[data-view]').forEach(item => { item.classList.toggle('active', item.dataset.view === view); }); // Cargar datos específicos de la vista if (view === 'usuarios') { const esAdmin = currentUser.rol === 'admin'; const puedeGestionar = esAdmin || (currentUser.permisos || []).includes('usuarios'); if (puedeGestionar) { cargarUsuarios(); } } if (view === 'categorias') { cargarCategorias(); } if (view === 'productos') { cargarCategoriasEnUpload(); cargarProductosDisponibles(); } if (view === 'agotados') { cargarAgotados(); } if (view === 'descargas') { cargarDescargas(); } if (view === 'pedidos') { cargarPedidos(); } } async function cargarEstadisticas() { try { const [resCats, resProds] = await Promise.all([ fetch('/api/categorias'), fetch('/api/productos') ]); const categorias = await resCats.json(); const productos = await resProds.json(); cachedProductos = productos; document.getElementById('statProductos').textContent = productos.length; document.getElementById('statCategorias').textContent = categorias.length; } catch (err) { console.error('Error cargando estadísticas:', err); } } // ════════════════════════════════ // PERMISOS — visibilidad según rol + permisos[] // Vendedor: SIEMPRE ve 'descargas' (implícito) + lo que le hayan dado // ════════════════════════════════ function getPermisosEfectivos() { if (!currentUser) return []; const permisos = currentUser.permisos || []; if (currentUser.rol === 'vendedor' && !permisos.includes('descargas')) { return ['descargas', ...permisos]; } return permisos; } function aplicarPermisos() { if (!currentUser) return; const esAdmin = currentUser.rol === 'admin'; const perms = getPermisosEfectivos(); const puedeGestionarUsuarios = esAdmin || perms.includes('usuarios'); // Mostrar/ocultar cada nav-item según permisos document.querySelectorAll('.nav-item[data-view]').forEach(item => { const view = item.dataset.view; const visible = esAdmin || perms.includes(view); item.style.display = visible ? '' : 'none'; }); // Subsección "Mi contraseña" — SOLO admin const miPassSubsection = document.getElementById('miPasswordSubsection'); if (miPassSubsection) { miPassSubsection.style.display = esAdmin ? '' : 'none'; } // Subsección "Gestión de usuarios" — admin O permiso 'usuarios' const gestionSubsection = document.getElementById('gestionUsuariosSubsection'); if (gestionSubsection) { gestionSubsection.style.display = puedeGestionarUsuarios ? '' : 'none'; } } // ════════════════════════════════ // NAVEGACIÓN // ════════════════════════════════ function setupNavigation() { document.querySelectorAll('.nav-item[data-view]').forEach(item => { item.addEventListener('click', () => { cambiarVista(item.dataset.view); }); }); } // ════════════════════════════════ // SUBTABS (dentro de la view 'descargas') // ════════════════════════════════ function setupSubtabs() { document.querySelectorAll('.subtab').forEach(btn => { btn.addEventListener('click', () => { const subtab = btn.dataset.subtab; // Activar solo este botón btn.parentElement.querySelectorAll('.subtab').forEach(b => { b.classList.toggle('active', b === btn); b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); }); // Mostrar solo el panel correspondiente document.querySelectorAll('.subtab-panel').forEach(p => { p.classList.toggle('active', p.dataset.subtabPanel === subtab); }); }); }); } // ════════════════════════════════ // DESCARGAS (vista + subtabs pdf / fotos) // ════════════════════════════════ function cargarDescargas() { initGestores(); pdfGestor.setup(); fotosGestor.setup(); } // ════════════════════════════════ // PERMISOS EN MODALES — render y lectura // ════════════════════════════════ function renderPermisos(containerId, checkedPerms = []) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = TABS.map(t => ` `).join(''); } function collectPermisos(containerId) { const container = document.getElementById(containerId); if (!container) return []; return Array.from(container.querySelectorAll('input[type="checkbox"]:checked')) .map(cb => cb.value); } // Mostrar/ocultar la sección de permisos según el rol elegido en el ${nodo.icono || '📦'} ${escapeHtml(nodo.nombre)} ${count} `; if (tieneHijos) { const childrenHtml = nodo.subcategorias .map(s => renderNodoHtml(s, [...pathArr, s.id])) .join(''); html += `
${childrenHtml}
`; } return html; } async function cargarArbol() { const cont = document.getElementById(selectorId); cont.innerHTML = '

Cargando categorías…

'; try { const [resCats, resProds] = await Promise.all([ fetch('/api/categorias', { credentials: 'include' }), fetch('/api/productos', { credentials: 'include' }) ]); if (!resCats.ok) throw new Error('Error al cargar categorías'); if (!resProds.ok) throw new Error('Error al cargar productos'); state.arbol = await resCats.json(); state.productos = await resProds.json(); state.seleccionHojas = new Set(); renderArbol(); } catch (err) { console.error(`[${selectorId}] Error cargando datos:`, err); cont.innerHTML = '

Error al cargar. Recarga la pestaña.

'; } } function renderArbol() { const cont = document.getElementById(selectorId); const catsVisibles = state.arbol.filter(c => c.id !== 'sin-categoria'); if (catsVisibles.length === 0) { cont.innerHTML = '

No hay categorías para mostrar.

'; return; } cont.innerHTML = catsVisibles.map(cat => renderNodoHtml(cat, [cat.id])).join(''); cont.querySelectorAll('.pdf-check input').forEach(input => { input.addEventListener('change', onCheckChange); }); actualizarResumen(); } function onCheckChange(e) { const input = e.target; const key = input.dataset.key; const hasChildren = input.dataset.hasChildren === '1'; const isChecked = input.checked; if (input.disabled) { input.checked = false; return; } if (hasChildren) { setDescendantsChecked(key, isChecked); } else { if (isChecked) { state.seleccionHojas.add(key); } else { state.seleccionHojas.delete(key); } } input.indeterminate = false; updateAncestorsState(); actualizarResumen(); } function setDescendantsChecked(parentKey, isChecked) { const cont = document.getElementById(selectorId); const inputs = cont.querySelectorAll(`.pdf-check input[data-key^="${CSS.escape(parentKey)}|"]`); inputs.forEach(child => { if (child.disabled) return; child.checked = isChecked; child.indeterminate = false; if (child.dataset.leaf === '1') { if (isChecked) { state.seleccionHojas.add(child.dataset.key); } else { state.seleccionHojas.delete(child.dataset.key); } } }); } function updateAncestorsState() { const cont = document.getElementById(selectorId); const inputs = cont.querySelectorAll('.pdf-check input[data-has-children="1"]'); inputs.forEach(input => { const key = input.dataset.key; const leafInputs = cont.querySelectorAll( `.pdf-check input[data-leaf="1"][data-key^="${CSS.escape(key)}|"]` ); let checked = 0, total = 0; leafInputs.forEach(leaf => { if (leaf.disabled) return; total++; if (leaf.checked) checked++; }); input.checked = total > 0 && checked === total; input.indeterminate = checked > 0 && checked < total; }); } function marcarTodo() { const cont = document.getElementById(selectorId); cont.querySelectorAll('.pdf-check input').forEach(input => { if (input.disabled) return; input.checked = true; input.indeterminate = false; if (input.dataset.leaf === '1') { state.seleccionHojas.add(input.dataset.key); } }); updateAncestorsState(); actualizarResumen(); } function desmarcarTodo() { state.seleccionHojas.clear(); const cont = document.getElementById(selectorId); cont.querySelectorAll('.pdf-check input').forEach(input => { input.checked = false; input.indeterminate = false; }); actualizarResumen(); } function actualizarResumen() { const summary = document.getElementById(summaryId); const countEl = document.getElementById(summaryCountId); const leavesEl = document.getElementById(summaryLeavesId); let totalProductos = 0; for (const key of state.seleccionHojas) { const parts = key.split('|'); const catId = parts[0]; const subArr = parts.slice(1); totalProductos += contarProductosEnRama(catId, subArr); } if (state.seleccionHojas.size === 0) { summary.style.display = 'none'; } else { summary.style.display = ''; countEl.textContent = totalProductos; leavesEl.textContent = state.seleccionHojas.size; } const btn = document.getElementById(btnGenerateId); if (btn) btn.disabled = state.seleccionHojas.size === 0; } async function generar() { const btn = document.getElementById(btnGenerateId); const errEl = document.getElementById(errorId); errEl.textContent = ''; errEl.classList.remove('show'); if (state.seleccionHojas.size === 0) { errEl.textContent = 'Selecciona al menos una hoja'; errEl.classList.add('show'); return; } const seleccion = Array.from(state.seleccionHojas).map(key => { const parts = key.split('|'); return { cat: parts[0], sub: parts.slice(1) }; }); btn.disabled = true; const spinner = btn.querySelector('.btn-spinner'); const btnText = btn.querySelector('.btn-text'); btnText.textContent = 'Generando…'; if (spinner) spinner.style.display = 'inline-block'; try { const res = await fetch(generateUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ seleccion }) }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || 'Error al generar la descarga'); } const blob = await res.blob(); if (extractZipOnNonSafari && !isSafari()) { const zip = await JSZip.loadAsync(blob); const entries = []; zip.forEach((relPath, zipEntry) => entries.push({ relPath, zipEntry })); btnText.textContent = `Extrayendo ${entries.length} foto(s)…`; for (const { relPath, zipEntry } of entries) { const imgBlob = await zipEntry.async('blob'); const imgUrl = URL.createObjectURL(imgBlob); const a = document.createElement('a'); a.href = imgUrl; a.download = relPath; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(imgUrl); await new Promise(r => setTimeout(r, 300)); } } else { const dispo = res.headers.get('Content-Disposition') || ''; const match = dispo.match(/filename="(.+)"/); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = match ? match[1] : fallbackFilename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btnText.textContent = generateBtnText; if (spinner) spinner.style.display = 'none'; actualizarResumen(); } } function setup() { const btnGen = document.getElementById(btnGenerateId); const btnAll = document.getElementById(btnAllId); const btnNone = document.getElementById(btnNoneId); if (btnGen && !btnGen.dataset.wired) { btnGen.addEventListener('click', generar); btnGen.dataset.wired = '1'; } if (btnAll && !btnAll.dataset.wired) { btnAll.addEventListener('click', marcarTodo); btnAll.dataset.wired = '1'; } if (btnNone && !btnNone.dataset.wired) { btnNone.addEventListener('click', desmarcarTodo); btnNone.dataset.wired = '1'; } cargarArbol(); } return { setup, state, marcarTodo, desmarcarTodo, generar }; } // Instancias del gestor para cada subtab let pdfGestor; let fotosGestor; // Inicialización diferida en cargarDescargas() function initGestores() { if (pdfGestor) return; pdfGestor = createDescargaGestor({ selectorId: 'pdfSelector', summaryId: 'pdfSummary', summaryCountId: 'pdfSummaryCount', summaryLeavesId: 'pdfSummaryLeaves', btnGenerateId: 'btnPdfGenerar', btnAllId: 'btnPdfMarcarTodo', btnNoneId: 'btnPdfDesmarcarTodo', errorId: 'pdfError', generateUrl: '/api/descargas/pdf', generateBtnText: '📄 Generar PDF', fallbackFilename: 'catalogo-infinity.pdf' }); fotosGestor = createDescargaGestor({ selectorId: 'fotosSelector', summaryId: 'fotosSummary', summaryCountId: 'fotosSummaryCount', summaryLeavesId: 'fotosSummaryLeaves', btnGenerateId: 'btnFotosGenerar', btnAllId: 'btnFotosMarcarTodo', btnNoneId: 'btnFotosDesmarcarTodo', errorId: 'fotosError', generateUrl: '/api/descargas/fotos', generateBtnText: '🖼️ Descargar fotos', fallbackFilename: 'fotos-infinity.zip', extractZipOnNonSafari: true }); } // cargarDescargas() ahora usa los gestores // (definido arriba en la sección DESCARGAS)