/* ════════════════════════════════ 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: '👥' } ]; 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(); } } 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) // Funcionalidad TBD — placeholders por ahora // ════════════════════════════════ function cargarDescargas() { // Placeholder. Cuando se implemente cada subpestaña, se cargan // los datos del backend (PDFs disponibles, lotes de fotos, etc.). } // ════════════════════════════════ // PERMISOS EN MODALES — render y lectura // ════════════════════════════════ function renderPermisos(containerId, checkedPerms = []) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = TABS.map(t => ` ${t.icon} ${t.label} `).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 function setupPermisosToggles() { const pairs = [ { select: 'nuevoRol', group: 'permisosGroupCrear', note: 'permisosNoteCrear', container: 'permisosCrear' }, { select: 'editarRol', group: 'permisosGroupEditar', note: 'permisosNoteEditar', container: 'permisosEditar' } ]; pairs.forEach(p => { const sel = document.getElementById(p.select); if (!sel) return; const update = () => { const isAdmin = sel.value === 'admin'; document.getElementById(p.group).style.display = isAdmin ? 'none' : ''; document.getElementById(p.note).style.display = isAdmin ? 'flex' : 'none'; }; sel.addEventListener('change', update); }); } // ════════════════════════════════ // LOGIN // ════════════════════════════════ function setupLoginForm() { const form = document.getElementById('loginForm'); const errorEl = document.getElementById('formError'); form.addEventListener('submit', async e => { e.preventDefault(); errorEl.classList.remove('show'); errorEl.textContent = ''; const usuario = document.getElementById('usuario').value.trim(); const contraseña = document.getElementById('contraseña').value; if (!usuario || !contraseña) return; const btn = form.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = 'Verificando…'; try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ usuario, contraseña }) }); const data = await res.json(); if (!res.ok) { mostrarError(data.error || 'Error al iniciar sesión'); return; } // Re-fetch /me para obtener permisos actualizados const meRes = await fetch('/api/auth/me', { credentials: 'include' }); const me = await meRes.json(); mostrarDashboard({ usuario: me.usuario, rol: me.rol, permisos: me.permisos || [] }); } catch (err) { console.error(err); mostrarError('Error de conexión. Intenta de nuevo.'); } finally { btn.disabled = false; btn.textContent = 'Ingresar'; } }); } function mostrarError(msg) { const el = document.getElementById('formError'); el.textContent = msg; el.classList.add('show'); } // ════════════════════════════════ // LOGOUT // ════════════════════════════════ function setupLogout() { document.getElementById('btnLogout').addEventListener('click', async () => { try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch (err) { console.error('Error en logout:', err); } document.getElementById('usuario').value = ''; document.getElementById('contraseña').value = ''; mostrarLogin(); }); } // ════════════════════════════════ // USUARIOS (admin) // ════════════════════════════════ async function cargarUsuarios() { const tbody = document.getElementById('usersListBody'); tbody.innerHTML = 'Cargando…'; try { const res = await fetch('/api/usuarios', { credentials: 'include' }); if (!res.ok) { if (res.status === 401 || res.status === 403) { mostrarLogin(); return; } throw new Error('Error al cargar usuarios'); } const usuarios = await res.json(); renderUsuarios(usuarios); } catch (err) { console.error(err); tbody.innerHTML = 'Error al cargar usuarios'; } } function renderUsuarios(usuarios) { const tbody = document.getElementById('usersListBody'); const esAdmin = currentUser.rol === 'admin'; const puedeGestionar = esAdmin || (currentUser.permisos || []).includes('usuarios'); // Ocultar/mostrar la columna "Acciones" (header + celdas) document.querySelectorAll('.users-table .users-actions-col, .users-table .users-actions') .forEach(el => el.style.display = puedeGestionar ? '' : 'none'); if (usuarios.length === 0) { tbody.innerHTML = `No hay usuarios aún`; return; } tbody.innerHTML = usuarios.map(u => { const isYou = u.usuario === currentUser.usuario; return ` ${escapeHtml(u.usuario)} ${isYou ? 'tú' : ''} ${ROLES[u.rol] || u.rol} ${puedeGestionar ? ` ✏️ ${isYou ? '' : `🗑️`} ` : ''} `; }).join(''); } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); } function escapeAttr(str) { return String(str).replace(/'/g, "\\'"); } async function crearUsuario(usuario, contraseña, rol, permisos) { const res = await fetch('/api/usuarios', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ usuario, contraseña, rol, permisos }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al crear usuario'); return true; } async function editarUsuario(targetUsuario, payload) { const res = await fetch(`/api/usuarios/${encodeURIComponent(targetUsuario)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(payload) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al actualizar usuario'); return true; } async function eliminarUsuario(targetUsuario) { const res = await fetch(`/api/usuarios/${encodeURIComponent(targetUsuario)}`, { method: 'DELETE', credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al eliminar usuario'); return true; } async function cambiarMiPassword(actual, nueva) { const res = await fetch('/api/auth/password', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ actual, nueva }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al cambiar contraseña'); return true; } // ════════════════════════════════ // MODALES // ════════════════════════════════ function setupModals() { // Cerrar modales con click en backdrop o botón document.querySelectorAll('[data-close-modal]').forEach(el => { el.addEventListener('click', () => { const id = el.dataset.closeModal; cerrarModal(id); }); }); // Crear usuario document.getElementById('btnCrearUsuario').addEventListener('click', () => { document.getElementById('crearUsuarioError').classList.remove('show'); document.getElementById('crearUsuarioError').textContent = ''; // Render checkboxes vacíos (nuevo editor sin permisos) renderPermisos('permisosCrear', []); // Asegurar que la sección de permisos sea visible (rol default = editor) document.getElementById('permisosGroupCrear').style.display = ''; document.getElementById('permisosNoteCrear').style.display = 'none'; abrirModal('modalCrearUsuario'); }); document.getElementById('formCrearUsuario').addEventListener('submit', async e => { e.preventDefault(); const usuario = document.getElementById('nuevoUsuario').value.trim(); const contraseña = document.getElementById('nuevoPassword').value; const rol = document.getElementById('nuevoRol').value; const permisos = rol === 'admin' ? [] : collectPermisos('permisosCrear'); const errEl = document.getElementById('crearUsuarioError'); const btn = e.target.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = 'Creando…'; try { await crearUsuario(usuario, contraseña, rol, permisos); cerrarModal('modalCrearUsuario'); await cargarUsuarios(); cargarEstadisticas(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Crear'; } }); // Editar usuario document.getElementById('formEditarUsuario').addEventListener('submit', async e => { e.preventDefault(); if (!editingUser) return; const contraseña = document.getElementById('editarPassword').value; const rol = document.getElementById('editarRol').value; const permisos = rol === 'admin' ? [] : collectPermisos('permisosEditar'); const errEl = document.getElementById('editarUsuarioError'); const payload = { rol, permisos }; if (contraseña) payload.contraseña = contraseña; const btn = e.target.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = 'Guardando…'; try { await editarUsuario(editingUser, payload); editingUser = null; cerrarModal('modalEditarUsuario'); await cargarUsuarios(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Guardar cambios'; } }); // Confirmar eliminar document.getElementById('btnConfirmarEliminar').addEventListener('click', async () => { if (!deletingUser) return; const errEl = document.getElementById('eliminarUsuarioError'); const btn = document.getElementById('btnConfirmarEliminar'); btn.disabled = true; btn.textContent = 'Eliminando…'; try { await eliminarUsuario(deletingUser); deletingUser = null; cerrarModal('modalConfirmarEliminar'); await cargarUsuarios(); cargarEstadisticas(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Sí, eliminar'; } }); // Form inline de cambio de contraseña document.getElementById('formMiPassword').addEventListener('submit', async e => { e.preventDefault(); const actual = document.getElementById('miPassActual').value; const nueva = document.getElementById('miPassNueva').value; const confirmar = document.getElementById('miPassConfirmar').value; const errEl = document.getElementById('miPassError'); if (nueva !== confirmar) { errEl.textContent = 'Las contraseñas nuevas no coinciden'; errEl.classList.add('show'); return; } const btn = e.target.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = 'Guardando…'; try { await cambiarMiPassword(actual, nueva); errEl.classList.remove('show'); e.target.reset(); btn.textContent = '✓ Guardado'; btn.style.background = 'linear-gradient(135deg, #10b981, #34d399)'; setTimeout(() => { btn.textContent = 'Guardar cambios'; btn.style.background = ''; btn.disabled = false; }, 2000); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); btn.disabled = false; btn.textContent = 'Guardar cambios'; } }); } function abrirEditarUsuario(usuario) { editingUser = usuario; document.getElementById('editarUsuarioNombre').value = usuario; document.getElementById('editarPassword').value = ''; // obtener datos actuales del usuario fetch('/api/usuarios', { credentials: 'include' }) .then(r => r.json()) .then(usuarios => { const u = usuarios.find(x => x.usuario === usuario); if (!u) return; document.getElementById('editarRol').value = u.rol; // Si es admin, ocultar checkboxes y mostrar nota const group = document.getElementById('permisosGroupEditar'); const note = document.getElementById('permisosNoteEditar'); if (u.rol === 'admin') { group.style.display = 'none'; note.style.display = 'flex'; } else { group.style.display = ''; note.style.display = 'none'; renderPermisos('permisosEditar', u.permisos || []); } }); document.getElementById('editarUsuarioError').classList.remove('show'); document.getElementById('editarUsuarioError').textContent = ''; abrirModal('modalEditarUsuario'); } function abrirEliminarUsuario(usuario) { deletingUser = usuario; document.getElementById('eliminarUsuarioNombre').textContent = usuario; document.getElementById('eliminarUsuarioError').classList.remove('show'); document.getElementById('eliminarUsuarioError').textContent = ''; abrirModal('modalConfirmarEliminar'); } function abrirModal(id) { const modal = document.getElementById(id); modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; } function cerrarModal(id) { const modal = document.getElementById(id); modal.style.display = 'none'; document.body.style.overflow = ''; const form = modal.querySelector('form'); if (form) form.reset(); modal.querySelectorAll('.form-error').forEach(el => { el.classList.remove('show'); el.textContent = ''; }); } // Escape cierra modales document.addEventListener('keydown', e => { if (e.key === 'Escape') { document.querySelectorAll('.modal').forEach(m => { if (m.style.display === 'flex') m.style.display = 'none'; }); document.body.style.overflow = ''; } }); // ════════════════════════════════ // CATEGORÍAS (admin o permiso 'categorias') // ════════════════════════════════ async function cargarCategorias() { const grid = document.getElementById('categoriasGrid'); grid.innerHTML = 'Cargando…'; try { const [resCats, resProds] = await Promise.all([ fetch('/api/categorias'), fetch('/api/productos') ]); if (!resCats.ok) { if (resCats.status === 401 || resCats.status === 403) { mostrarLogin(); return; } throw new Error('Error al cargar categorías'); } const categorias = await resCats.json(); const productos = resProds.ok ? await resProds.json() : []; cachedProductos = productos; renderCategorias(categorias, productos); } catch (err) { console.error(err); grid.innerHTML = 'Error al cargar categorías'; } } function renderCategorias(categorias, productos) { const grid = document.getElementById('categoriasGrid'); if (categorias.length === 0) { grid.innerHTML = 'No hay categorías. Crea la primera con el botón "+ Crear categoría".'; return; } // Conteo por categoría const countByCat = {}; productos.forEach(p => { countByCat[p.cat] = (countByCat[p.cat] || 0) + 1; }); grid.innerHTML = categorias.map(c => { const count = countByCat[c.id] || 0; const isSistema = c.id === 'sin-categoria'; const subcats = Array.isArray(c.subcategorias) ? c.subcategorias : []; const tieneSubcats = subcats.length > 0; return ` ${c.icono || '📦'} ${escapeHtml(c.nombre)} ${escapeHtml(c.id)} ${count} item${count === 1 ? '' : 's'} ${!isSistema ? ` ⋯ ${escapeHtml(c.nombre)} + Agregar subcategoría ✏️ Editar nombre e icono 🗑️ Eliminar categoría ` : ''} ${tieneSubcats ? `${renderSubcatRows(subcats, [c.id])}` : ''} `; }).join(''); } function renderIconPicker(selectedIcon) { const picker = document.getElementById('iconPicker'); picker.innerHTML = CAT_ICONS.map(icon => ` ${icon} `).join(''); picker.querySelectorAll('.icon-option').forEach(btn => { btn.addEventListener('click', () => { picker.querySelectorAll('.icon-option').forEach(b => b.classList.remove('selected')); btn.classList.add('selected'); document.getElementById('catIcono').value = btn.dataset.icon; }); }); } // Abrir modal para CREAR categoría function abrirCrearCategoria() { editingCategoria = null; document.getElementById('modalCategoriaTitulo').textContent = 'Crear categoría'; document.getElementById('btnGuardarCategoria').textContent = 'Crear'; document.getElementById('catNombre').value = ''; document.getElementById('catIdHint').textContent = ''; document.getElementById('categoriaError').classList.remove('show'); document.getElementById('categoriaError').textContent = ''; renderIconPicker('📦'); abrirModal('modalCategoria'); // Mostrar hint en vivo con el slug generado document.getElementById('catNombre').oninput = e => { const slug = slugifyLocal(e.target.value); document.getElementById('catIdHint').textContent = slug ? `ID: ${slug}` : ''; }; } // Abrir modal para EDITAR categoría function abrirEditarCategoria(id) { fetch('/api/categorias', { credentials: 'include' }) .then(r => r.json()) .then(categorias => { const c = categorias.find(x => x.id === id); if (!c) return; editingCategoria = id; document.getElementById('modalCategoriaTitulo').textContent = 'Editar categoría'; document.getElementById('btnGuardarCategoria').textContent = 'Guardar cambios'; document.getElementById('catNombre').value = c.nombre; document.getElementById('catIdHint').textContent = `ID: ${c.id} (no se puede cambiar)`; document.getElementById('categoriaError').classList.remove('show'); document.getElementById('categoriaError').textContent = ''; renderIconPicker(c.icono || '📦'); abrirModal('modalCategoria'); }); } function abrirEliminarCategoria(id, nombre) { deletingCategoria = id; const count = cachedProductos.filter(p => p.cat === id).length; document.getElementById('eliminarCatNombre').textContent = nombre; document.getElementById('eliminarCatCount').textContent = count; document.getElementById('eliminarCatItemsMsg').style.display = count > 0 ? 'flex' : 'none'; document.getElementById('eliminarCatError').classList.remove('show'); document.getElementById('eliminarCatError').textContent = ''; abrirModal('modalEliminarCategoria'); } // Slugify en el frontend (mismo criterio que el backend) function slugifyLocal(text) { return String(text) .toLowerCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); } async function guardarCategoria(e) { e.preventDefault(); const nombre = document.getElementById('catNombre').value.trim(); const icono = document.getElementById('catIcono').value; const errEl = document.getElementById('categoriaError'); const btn = document.getElementById('btnGuardarCategoria'); if (!nombre) return; btn.disabled = true; btn.textContent = editingCategoria ? 'Guardando…' : 'Creando…'; try { const url = editingCategoria ? `/api/categorias/${encodeURIComponent(editingCategoria)}` : '/api/categorias'; const method = editingCategoria ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ nombre, icono }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al guardar'); editingCategoria = null; cerrarModal('modalCategoria'); await cargarCategorias(); cargarEstadisticas(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = editingCategoria ? 'Guardar cambios' : 'Crear'; } } async function confirmarEliminarCategoria() { if (!deletingCategoria) return; const btn = document.getElementById('btnConfirmarEliminarCategoria'); const errEl = document.getElementById('eliminarCatError'); btn.disabled = true; btn.textContent = 'Eliminando…'; try { const res = await fetch(`/api/categorias/${encodeURIComponent(deletingCategoria)}`, { method: 'DELETE', credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al eliminar'); deletingCategoria = null; cerrarModal('modalEliminarCategoria'); await cargarCategorias(); cargarEstadisticas(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Sí, eliminar'; } } // Setup de listeners para categorías (se llama desde setupModals) function setupCategoriasModals() { document.getElementById('btnCrearCategoria').addEventListener('click', abrirCrearCategoria); document.getElementById('formCategoria').addEventListener('submit', guardarCategoria); document.getElementById('btnConfirmarEliminarCategoria').addEventListener('click', confirmarEliminarCategoria); document.getElementById('formSubcategoria').addEventListener('submit', guardarSubcategoria); document.getElementById('btnConfirmarEliminarSubcat').addEventListener('click', confirmarEliminarSubcategoria); setupKebabMenus(); } // ════════════════════════════════ // MENÚ KEBAB (drop-down ⋯) — para categoría y subcats // - Click en .menu-toggle → abre/cierra // - Click en .menu-item → ejecuta acción, cierra // - Click fuera / Escape → cierra todos // - Al abrir, marca .menu-active en el contenedor para que no se oculte al perder hover // ════════════════════════════════ function setupKebabMenus() { document.addEventListener('click', e => { // Click en el toggle ⋯ const toggle = e.target.closest('.menu-toggle'); if (toggle) { e.stopPropagation(); const dropdown = toggle.parentElement; const panel = dropdown.querySelector('.menu-panel'); if (!panel) return; const wasOpen = !panel.hidden; closeAllKebabMenus(); if (!wasOpen) { panel.hidden = false; toggle.setAttribute('aria-expanded', 'true'); // Marcar la card como host para que flote sobre los vecinos const card = dropdown.closest('.categoria-card'); if (card) card.classList.add('menu-host'); } return; } // Click en un item del menú const item = e.target.closest('.menu-item'); if (item) { const action = item.dataset.action; closeAllKebabMenus(); if (action === 'add-sub-cat') abrirCrearSubcategoria(item.dataset.id); else if (action === 'add-sub-sub-cat') abrirCrearSubcatHijo(item.dataset.cat, item.dataset.sub); else if (action === 'edit-subcat') abrirEditarSubcategoria(item.dataset.cat, item.dataset.sub); else if (action === 'delete-subcat') abrirEliminarSubcategoria(item.dataset.cat, item.dataset.sub, item.dataset.name); else if (action === 'edit-cat') abrirEditarCategoria(item.dataset.id); else if (action === 'delete-cat') abrirEliminarCategoria(item.dataset.id, item.dataset.name); return; } // Click fuera de cualquier dropdown if (!e.target.closest('.menu-dropdown')) { closeAllKebabMenus(); } }); // Escape cierra todos document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAllKebabMenus(); }); } function closeAllKebabMenus() { document.querySelectorAll('.menu-panel').forEach(p => p.hidden = true); document.querySelectorAll('.menu-toggle').forEach(b => b.setAttribute('aria-expanded', 'false')); document.querySelectorAll('.categoria-card.menu-host') .forEach(c => c.classList.remove('menu-host')); } // ════════════════════════════════ // SUBCATEGORÍAS (CRUD sobre el árbol anidado) // Estado: editingSubcat = { parentId, subId } cuando se está editando // deletingSubcat = { parentId, subId, nombre } cuando se va a eliminar // ════════════════════════════════ let editingSubcat = null; let deletingSubcat = null; // Render recursivo de subcategorías como filas anidadas dentro de la card de la categoría function renderSubcatRows(subcats, parentPath) { if (!Array.isArray(subcats) || subcats.length === 0) return ''; // parentPath = [c.id] → render nivel 2 (subs). El + aparece (pueden tener sub-subs). // parentPath = [c.id, s.id] → render nivel 3 (sub-subs). El + NO aparece (límite 3 niveles). const puedenTenerHijos = parentPath.length === 1; return subcats.map(s => { const path = [...parentPath, s.id]; const tieneSubSub = Array.isArray(s.subcategorias) && s.subcategorias.length > 0; return ` ${escapeHtml(s.nombre)} ${escapeHtml(s.id)} ⋯ ${escapeHtml(s.nombre)} ${puedenTenerHijos ? ` + Agregar sub-subcategoría ` : ''} ✏️ Editar nombre 🗑️ Eliminar subcategoría ${tieneSubSub ? `${renderSubcatRows(s.subcategorias, path)}` : ''} `; }).join(''); } async function abrirCrearSubcategoria(parentId) { editingSubcat = null; document.getElementById('modalSubcategoriaTitulo').textContent = 'Crear subcategoría'; document.getElementById('btnGuardarSubcategoria').textContent = 'Crear'; document.getElementById('subcatNombre').value = ''; document.getElementById('subcatIdHint').textContent = ''; document.getElementById('subcategoriaError').classList.remove('show'); document.getElementById('subcategoriaError').textContent = ''; // Mostrar breadcrumb del padre (solo la categoría) const categorias = await fetch('/api/categorias', { credentials: 'include' }).then(r => r.json()); const parent = categorias.find(c => c.id === parentId); if (parent) { document.getElementById('subcatBreadcrumb').innerHTML = `${parent.icono || '📦'} ${escapeHtml(parent.nombre)}›Nueva subcategoría`; } abrirModal('modalSubcategoria'); document.getElementById('subcatNombre').oninput = e => { const slug = slugifyLocal(e.target.value); document.getElementById('subcatIdHint').textContent = slug ? `ID: ${slug}` : ''; }; // Guardar el parentId en el dataset del form para usarlo al enviar document.getElementById('formSubcategoria').dataset.parentId = parentId; document.getElementById('formSubcategoria').dataset.subId = ''; } async function abrirCrearSubcatHijo(parentCatId, parentSubId) { // Para crear una sub-subcategoría, el "padre" es la subcategoría (no la categoría raíz) editingSubcat = null; document.getElementById('modalSubcategoriaTitulo').textContent = 'Crear sub-subcategoría'; document.getElementById('btnGuardarSubcategoria').textContent = 'Crear'; document.getElementById('subcatNombre').value = ''; document.getElementById('subcatIdHint').textContent = ''; document.getElementById('subcategoriaError').classList.remove('show'); document.getElementById('subcategoriaError').textContent = ''; const categorias = await fetch('/api/categorias', { credentials: 'include' }).then(r => r.json()); const path = getCategoryPathCliente(categorias, parentSubId); if (path) { const crumbs = path.map(n => `${n.icono || '📦'} ${escapeHtml(n.nombre)}›`).join(''); document.getElementById('subcatBreadcrumb').innerHTML = crumbs + 'Nueva sub-subcategoría'; } abrirModal('modalSubcategoria'); document.getElementById('subcatNombre').oninput = e => { const slug = slugifyLocal(e.target.value); document.getElementById('subcatIdHint').textContent = slug ? `ID: ${slug}` : ''; }; document.getElementById('formSubcategoria').dataset.parentId = parentCatId; document.getElementById('formSubcategoria').dataset.subId = parentSubId; } async function abrirEditarSubcategoria(parentCatId, subId) { const categorias = await fetch('/api/categorias', { credentials: 'include' }).then(r => r.json()); const sub = findSubcatCliente(categorias, subId); if (!sub) return; editingSubcat = { parentCatId, subId }; document.getElementById('modalSubcategoriaTitulo').textContent = 'Editar subcategoría'; document.getElementById('btnGuardarSubcategoria').textContent = 'Guardar cambios'; document.getElementById('subcatNombre').value = sub.nombre; document.getElementById('subcatIdHint').textContent = `ID: ${sub.id} (no se puede cambiar)`; document.getElementById('subcategoriaError').classList.remove('show'); document.getElementById('subcategoriaError').textContent = ''; const path = getCategoryPathCliente(categorias, subId); if (path) { const crumbs = path.slice(0, -1).map(n => `${n.icono || '📦'} ${escapeHtml(n.nombre)}›`).join(''); document.getElementById('subcatBreadcrumb').innerHTML = crumbs + `${escapeHtml(sub.nombre)}`; } abrirModal('modalSubcategoria'); document.getElementById('formSubcategoria').dataset.parentId = parentCatId; document.getElementById('formSubcategoria').dataset.subId = subId; } function abrirEliminarSubcategoria(parentCatId, subId, nombre) { deletingSubcat = { parentCatId, subId, nombre }; document.getElementById('eliminarSubcatNombre').textContent = nombre; document.getElementById('eliminarSubcatError').classList.remove('show'); document.getElementById('eliminarSubcatError').textContent = ''; abrirModal('modalEliminarSubcategoria'); } // Helpers cliente (espejo de los del backend) function getCategoryPathCliente(categorias, id, trail = []) { for (const c of categorias) { const next = [...trail, c]; if (c.id === id) return next; if (Array.isArray(c.subcategorias) && c.subcategorias.length) { const found = getCategoryPathCliente(c.subcategorias, id, next); if (found) return found; } } return null; } function findSubcatCliente(categorias, id) { for (const c of categorias) { if (Array.isArray(c.subcategorias)) { const found = findInSubcatsCliente(c.subcategorias, id); if (found) return found; } } return null; } function findInSubcatsCliente(subcats, id) { for (const s of subcats) { if (s.id === id) return s; if (Array.isArray(s.subcategorias) && s.subcategorias.length) { const found = findInSubcatsCliente(s.subcategorias, id); if (found) return found; } } return null; } async function guardarSubcategoria(e) { e.preventDefault(); const nombre = document.getElementById('subcatNombre').value.trim(); const errEl = document.getElementById('subcategoriaError'); const btn = document.getElementById('btnGuardarSubcategoria'); const form = document.getElementById('formSubcategoria'); const parentId = form.dataset.parentId; const subId = form.dataset.subId; if (!nombre) return; btn.disabled = true; btn.textContent = editingSubcat ? 'Guardando…' : 'Creando…'; try { let url, method, body; if (editingSubcat) { // EDITAR: PUT /api/categorias/:parentId/subcategorias/:subId url = `/api/categorias/${encodeURIComponent(parentId)}/subcategorias/${encodeURIComponent(subId)}`; method = 'PUT'; body = { nombre }; } else if (subId) { // CREAR sub-subcat: POST con parentSubId en el body url = `/api/categorias/${encodeURIComponent(parentId)}/subcategorias`; method = 'POST'; body = { nombre, parentSubId: subId }; } else { // CREAR subcat nivel 1: POST simple url = `/api/categorias/${encodeURIComponent(parentId)}/subcategorias`; method = 'POST'; body = { nombre }; } const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al guardar'); editingSubcat = null; cerrarModal('modalSubcategoria'); await cargarCategorias(); cargarEstadisticas(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = editingSubcat ? 'Guardar cambios' : 'Crear'; } } async function confirmarEliminarSubcategoria() { if (!deletingSubcat) return; const btn = document.getElementById('btnConfirmarEliminarSubcat'); const errEl = document.getElementById('eliminarSubcatError'); btn.disabled = true; btn.textContent = 'Eliminando…'; try { const res = await fetch( `/api/categorias/${encodeURIComponent(deletingSubcat.parentCatId)}/subcategorias/${encodeURIComponent(deletingSubcat.subId)}`, { method: 'DELETE', credentials: 'include' } ); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al eliminar'); deletingSubcat = null; cerrarModal('modalEliminarSubcategoria'); await cargarCategorias(); cargarEstadisticas(); } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Sí, eliminar'; } } // ════════════════════════════════ // SUBIDA MASIVA DE PRODUCTOS // ════════════════════════════════ const MAX_FILES_UPLOAD = 50; const MAX_SIZE_UPLOAD = 20 * 1024 * 1024; // 20MB const ACCEPTED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; // Referencia = nombre completo antes de la extensión, sin importar complejidad. const REF_VALID_CLIENTE = /^[a-zA-Z0-9]+(?:[-_a-zA-Z0-9]*[a-zA-Z0-9])?$/; const SIN_CAT_ID = 'sin-categoria'; let pendingFiles = []; // [{file, ref, num, isValid, motivo}] function parseFilenameCliente(name) { const base = name.replace(/\.[^.]+$/, '').trim(); if (!base) return null; if (!REF_VALID_CLIENTE.test(base)) return null; return { ref: base, num: 1 }; } async function cargarCategoriasEnUpload() { const select = document.getElementById('uploadCategoria'); if (!select) { console.warn('[upload] Select #uploadCategoria no encontrado en el DOM'); return; } try { const res = await fetch('/api/categorias', { credentials: 'same-origin' }); if (!res.ok) throw new Error('HTTP ' + res.status); const categorias = await res.json(); if (!Array.isArray(categorias)) throw new Error('Respuesta no es array'); // Cachear el árbol completo (con subcategorías) en el módulo para la cascada uploadCategoriasTree = categorias; const seleccionPrevia = select.value; select.innerHTML = '— Selecciona una categoría —'; categorias .filter(c => c.id !== SIN_CAT_ID) .forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = `${c.icono || '📦'} ${c.nombre}`; select.appendChild(opt); }); if (seleccionPrevia) select.value = seleccionPrevia; } catch (err) { console.error('[upload] Error cargando categorías:', err); const error = document.getElementById('uploadError'); if (error) error.textContent = 'No se pudieron cargar las categorías. Recarga la página.'; } } // Árbol completo cacheado para la cascada del upload let uploadCategoriasTree = []; // Se ejecuta cuando el usuario cambia la categoría function onCategoriaChange() { const catId = document.getElementById('uploadCategoria').value; const subGrp = document.getElementById('uploadSubcatGroup'); const subSel = document.getElementById('uploadSubcategoria'); const subSubGrp = document.getElementById('uploadSubSubcatGroup'); const subSubSel = document.getElementById('uploadSubSubcategoria'); // Reset subSel.innerHTML = '— Selecciona una subcategoría —'; subSubSel.innerHTML = '— Selecciona una sub-subcategoría —'; subGrp.style.display = 'none'; subSubGrp.style.display = 'none'; if (!catId) { actualizarBreadcrumbDestino(); updateSubmitButton(); return; } const cat = uploadCategoriasTree.find(c => c.id === catId); if (!cat) return; if (Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0) { subGrp.style.display = ''; cat.subcategorias.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = `${s.icono || '📂'} ${s.nombre}`; subSel.appendChild(opt); }); } actualizarBreadcrumbDestino(); updateSubmitButton(); } // Se ejecuta cuando el usuario cambia la subcategoría function onSubcategoriaChange() { const catId = document.getElementById('uploadCategoria').value; const subId = document.getElementById('uploadSubcategoria').value; const subSubGrp = document.getElementById('uploadSubSubcatGroup'); const subSubSel = document.getElementById('uploadSubSubcategoria'); subSubSel.innerHTML = '— Selecciona una sub-subcategoría —'; subSubGrp.style.display = 'none'; if (!catId || !subId) { actualizarBreadcrumbDestino(); updateSubmitButton(); return; } const cat = uploadCategoriasTree.find(c => c.id === catId); if (!cat || !Array.isArray(cat.subcategorias)) return; const sub = cat.subcategorias.find(s => s.id === subId); if (!sub) return; if (Array.isArray(sub.subcategorias) && sub.subcategorias.length > 0) { subSubGrp.style.display = ''; sub.subcategorias.forEach(ss => { const opt = document.createElement('option'); opt.value = ss.id; opt.textContent = `${ss.icono || '📂'} ${ss.nombre}`; subSubSel.appendChild(opt); }); } actualizarBreadcrumbDestino(); updateSubmitButton(); } // Muestra el breadcrumb del destino actual (cat > sub1 > sub2) function actualizarBreadcrumbDestino() { const cont = document.getElementById('uploadDestino'); const path = document.getElementById('uploadDestinoPath'); if (!cont || !path) return; const catId = document.getElementById('uploadCategoria').value; const subId = document.getElementById('uploadSubcategoria').value; const subSubId= document.getElementById('uploadSubSubcategoria').value; if (!catId) { cont.style.display = 'none'; return; } const cat = uploadCategoriasTree.find(c => c.id === catId); if (!cat) { cont.style.display = 'none'; return; } const parts = [`${cat.icono || '📦'} ${cat.nombre}`]; if (subId) { const sub = (cat.subcategorias || []).find(s => s.id === subId); if (sub) { parts.push(`${sub.icono || '📂'} ${sub.nombre}`); if (subSubId) { const subSub = (sub.subcategorias || []).find(s => s.id === subSubId); if (subSub) parts.push(`${subSub.icono || '📂'} ${subSub.nombre}`); } } } path.textContent = parts.join(' › '); cont.style.display = ''; } // Devuelve el array `sub` que se enviará al backend (la cadena hasta la hoja) function getSubPathActual() { const catId = document.getElementById('uploadCategoria').value; const subId = document.getElementById('uploadSubcategoria').value; const subSubId = document.getElementById('uploadSubSubcategoria').value; if (!catId) return []; const path = []; if (subId) path.push(subId); if (subSubId) path.push(subSubId); return path; } function setupProductosUpload() { const dropzone = document.getElementById('dropzone'); const inputFotos = document.getElementById('inputFotos'); const btnLimpiar = document.getElementById('btnLimpiarPreview'); const form = document.getElementById('formUploadProductos'); const select = document.getElementById('uploadCategoria'); const selectSub = document.getElementById('uploadSubcategoria'); const selectSubSub= document.getElementById('uploadSubSubcategoria'); if (!dropzone || !inputFotos) return; // Click en dropzone abre el selector dropzone.addEventListener('click', () => inputFotos.click()); // Drag & drop ['dragenter', 'dragover'].forEach(ev => { dropzone.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); dropzone.classList.add('is-dragover'); }); }); ['dragleave', 'drop'].forEach(ev => { dropzone.addEventListener(ev, e => { e.preventDefault(); e.stopPropagation(); dropzone.classList.remove('is-dragover'); }); }); dropzone.addEventListener('drop', e => { const files = Array.from(e.dataTransfer.files || []); addFiles(files); }); inputFotos.addEventListener('change', e => { addFiles(Array.from(e.target.files || [])); inputFotos.value = ''; // permite re-seleccionar el mismo archivo }); btnLimpiar.addEventListener('click', () => { pendingFiles = []; renderPreview(); }); // Cascada: categoría → subcategoría → sub-subcategoría select.addEventListener('change', onCategoriaChange); if (selectSub) selectSub.addEventListener('change', onSubcategoriaChange); if (selectSubSub) selectSubSub.addEventListener('change', updateSubmitButton); form.addEventListener('submit', submitUpload); } function addFiles(files) { const total = pendingFiles.length + files.length; if (total > MAX_FILES_UPLOAD) { mostrarError(`Máximo ${MAX_FILES_UPLOAD} archivos. Has seleccionado ${files.length} y ya tenías ${pendingFiles.length}.`); return; } for (const f of files) { if (!ACCEPTED_TYPES.includes(f.type)) { pendingFiles.push({ file: f, ref: null, num: null, isValid: false, motivo: 'Tipo no permitido' }); continue; } if (f.size > MAX_SIZE_UPLOAD) { pendingFiles.push({ file: f, ref: null, num: null, isValid: false, motivo: 'Supera 20MB' }); continue; } const parsed = parseFilenameCliente(f.name); if (!parsed) { pendingFiles.push({ file: f, ref: null, num: null, isValid: false, motivo: 'Nombre inválido' }); continue; } pendingFiles.push({ file: f, ref: parsed.ref, num: parsed.num, isValid: true, motivo: null }); } renderPreview(); } function renderPreview() { const section = document.getElementById('previewSection'); const grid = document.getElementById('previewGrid'); const counter = document.getElementById('previewCount'); counter.textContent = pendingFiles.length; if (pendingFiles.length === 0) { section.style.display = 'none'; updateSubmitButton(); return; } section.style.display = 'block'; grid.innerHTML = ''; pendingFiles.forEach((item, idx) => { const div = document.createElement('div'); div.className = 'preview-item' + (item.isValid ? '' : ' is-invalid'); const url = URL.createObjectURL(item.file); const img = document.createElement('img'); img.src = url; img.alt = item.file.name; img.onload = () => URL.revokeObjectURL(url); div.appendChild(img); const info = document.createElement('div'); info.className = 'preview-info'; if (item.isValid) { info.innerHTML = `${escapeHtml(item.ref)}1 producto`; } else { info.innerHTML = `${escapeHtml(item.file.name)}${escapeHtml(item.motivo)}`; } div.appendChild(info); if (!item.isValid) { const tag = document.createElement('div'); tag.className = 'preview-invalid-tag'; tag.textContent = 'Inválido'; div.appendChild(tag); } const btnRemove = document.createElement('button'); btnRemove.type = 'button'; btnRemove.className = 'preview-remove'; btnRemove.innerHTML = '✕'; btnRemove.setAttribute('aria-label', 'Quitar'); btnRemove.addEventListener('click', () => { pendingFiles.splice(idx, 1); renderPreview(); }); div.appendChild(btnRemove); grid.appendChild(div); }); updateSubmitButton(); } function updateSubmitButton() { const btn = document.getElementById('btnSubirProductos'); const catId = document.getElementById('uploadCategoria').value; const validos = pendingFiles.filter(p => p.isValid).length; if (!catId || validos === 0) { btn.disabled = true; return; } // Validar que la cadena llegue a una hoja const cat = uploadCategoriasTree.find(c => c.id === catId); if (!cat) { btn.disabled = true; return; } const catTieneSubcats = Array.isArray(cat.subcategorias) && cat.subcategorias.length > 0; if (catTieneSubcats) { const subId = document.getElementById('uploadSubcategoria').value; if (!subId) { btn.disabled = true; return; } const sub = cat.subcategorias.find(s => s.id === subId); if (!sub) { btn.disabled = true; return; } if (Array.isArray(sub.subcategorias) && sub.subcategorias.length > 0) { const subSubId = document.getElementById('uploadSubSubcategoria').value; if (!subSubId) { btn.disabled = true; return; } } } btn.disabled = false; } async function submitUpload(e) { e.preventDefault(); const select = document.getElementById('uploadCategoria'); const btn = document.getElementById('btnSubirProductos'); const error = document.getElementById('uploadError'); const summary= document.getElementById('uploadSummary'); error.textContent = ''; summary.style.display = 'none'; summary.innerHTML = ''; const validos = pendingFiles.filter(p => p.isValid); if (!select.value) { error.textContent = 'Selecciona una categoría'; return; } if (validos.length === 0) { error.textContent = 'No hay fotos válidas para subir'; return; } // Lock UI btn.disabled = true; btn.querySelector('.btn-text').textContent = 'Subiendo…'; btn.querySelector('.btn-spinner').style.display = 'inline-block'; const subPath = getSubPathActual(); const fd = new FormData(); fd.append('categoria', select.value); fd.append('sub', JSON.stringify(subPath)); validos.forEach(item => fd.append('fotos', item.file, item.file.name)); try { const res = await fetch('/api/productos/upload', { method: 'POST', body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error en la subida'); renderSummary(data.resumen, validos); // Limpiar lo que se subió bien const refsSubidos = new Set((data.resumen.creadosList || []).map(c => c.ref)); if (refsSubidos.size > 0) { pendingFiles = pendingFiles.filter(p => !p.isValid || !refsSubidos.has(p.ref)); renderPreview(); // refrescar stats if (typeof cargarEstadisticas === 'function') cargarEstadisticas(); } } catch (err) { error.textContent = err.message; } finally { btn.disabled = false; btn.querySelector('.btn-text').textContent = 'Subir fotos'; btn.querySelector('.btn-spinner').style.display = 'none'; updateSubmitButton(); } } function renderSummary(r, validos) { const cont = document.getElementById('uploadSummary'); cont.innerHTML = ''; if (r.creados > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-success'; row.innerHTML = ` ✅ ${r.creados} producto(s) creado(s) ${r.creadosList.map(c => `${escapeHtml(c.ref)}`).join('')} `; cont.appendChild(row); } if (r.saltados > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-warning'; row.innerHTML = ` ⏭️ ${r.saltados} referencia(s) ya existían — no se sobrescribieron ${r.saltadosList.map(s => `${escapeHtml(s.ref)} — ${escapeHtml(s.motivo)}`).join('')} `; cont.appendChild(row); } if (r.ignorados > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-warning'; row.innerHTML = ` ⚠️ ${r.ignorados} archivo(s) con nombre inválido — no se procesaron ${r.ignoradosList.map(i => `${escapeHtml(i.archivo)} — ${escapeHtml(i.motivo)}`).join('')} `; cont.appendChild(row); } if (r.errores > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-error'; row.innerHTML = ` ❌ ${r.errores} error(es) al procesar ${r.erroresList.map(e => `${escapeHtml(e.ref)} — ${escapeHtml(e.motivo)}`).join('')} `; cont.appendChild(row); } if (cont.children.length === 0) { cont.innerHTML = `ℹ️Nada se subió`; } cont.style.display = 'flex'; } // ════════════════════════════════ // PRODUCTOS DISPONIBLES (lista en vista Productos) // Solo se muestran los productos que NO están agotados // (img no empieza con "agotados/"). // ════════════════════════════════ let deletingProductoRef = null; let busquedaProductos = ''; // texto de la barra de búsqueda let productosCache = []; // últimos productos cargados (para filtrar en cliente) let categoriasCache = []; // últimas categorías cargadas (para iconos) async function cargarProductosDisponibles() { const grid = document.getElementById('productosListGrid'); if (!grid) return; grid.innerHTML = 'Cargando…'; try { const [resProds, resCats] = await Promise.all([ fetch('/api/productos', { credentials: 'include' }), fetch('/api/categorias', { credentials: 'include' }) ]); if (!resProds.ok) { if (resProds.status === 401 || resProds.status === 403) { mostrarLogin(); return; } throw new Error('Error al cargar productos'); } const productos = await resProds.json(); const categorias = resCats.ok ? await resCats.json() : []; cachedProductos = productos; productosCache = productos; categoriasCache = categorias; renderProductosDisponibles(); } catch (err) { console.error(err); grid.innerHTML = 'Error al cargar productos'; } } function renderProductosDisponibles() { const grid = document.getElementById('productosListGrid'); const count = document.getElementById('productosDisponiblesCount'); if (!grid) return; // Solo productos disponibles: img NO empieza con "agotados/" // (y descartamos sin-categoria para mantener consistencia con el catálogo público) let disponibles = productosCache.filter(p => p.cat && p.cat !== 'sin-categoria' && (!p.img || !p.img.startsWith('agotados/')) ); const total = disponibles.length; // Filtro por referencia (case-insensitive, match parcial) const q = busquedaProductos.trim().toLowerCase(); if (q) { disponibles = disponibles.filter(p => String(p.ref || '').toLowerCase().includes(q)); } // Contador: "X items" o "X / Y items" cuando hay filtro if (count) { if (q) { count.textContent = `${disponibles.length} / ${total} item${total === 1 ? '' : 's'}`; } else { count.textContent = `${total} item${total === 1 ? '' : 's'}`; } } if (total === 0) { grid.innerHTML = 'No hay productos disponibles aún. Sube fotos en el formulario de arriba.'; return; } if (disponibles.length === 0) { grid.innerHTML = `No se encontraron productos con la referencia "${escapeHtml(busquedaProductos)}".`; return; } // Ordenar por ref para tener un orden estable disponibles.sort((a, b) => String(a.ref).localeCompare(String(b.ref))); const categorias = categoriasCache; grid.innerHTML = disponibles.map(p => { const cat = categorias.find(c => c.id === p.cat); const icon = cat ? cat.icono : '📦'; const imgSrc = p.img ? (p.img.startsWith('/') ? p.img : `/Imagenes/${p.img}`) : ''; return ` ${icon} ${escapeHtml(p.ref)} ${icon} ${escapeHtml(cat ? cat.nombre : p.cat)} 🚫 `; }).join(''); } // Hook de la barra de búsqueda — se ejecuta una sola vez al cargar el DOM. function setupProductosSearch() { const input = document.getElementById('productosSearch'); const clear = document.getElementById('productosSearchClear'); if (!input || !clear) return; input.addEventListener('input', e => { busquedaProductos = e.target.value; clear.style.display = busquedaProductos ? 'flex' : 'none'; renderProductosDisponibles(); }); clear.addEventListener('click', () => { input.value = ''; busquedaProductos = ''; clear.style.display = 'none'; renderProductosDisponibles(); input.focus(); }); } // ════════════════════════════════ // EXCEL/CSV — asignar nombre + precio por referencia // ════════════════════════════════ function setupExcelUpload() { const input = document.getElementById('excelArchivo'); const hint = document.getElementById('excelArchivoHint'); const form = document.getElementById('formExcelUpload'); const btn = document.getElementById('btnProcesarExcel'); if (!input || !form) return; input.addEventListener('change', () => { const f = input.files && input.files[0]; if (f) { const sizeKB = (f.size / 1024).toFixed(1); hint.textContent = `📄 ${f.name} · ${sizeKB} KB`; } else { hint.textContent = 'Ningún archivo seleccionado'; } btn.disabled = !f; }); form.addEventListener('submit', submitExcelUpload); } async function submitExcelUpload(e) { e.preventDefault(); const input = document.getElementById('excelArchivo'); const btn = document.getElementById('btnProcesarExcel'); const errEl = document.getElementById('excelError'); const summ = document.getElementById('excelSummary'); const f = input.files && input.files[0]; errEl.textContent = ''; errEl.classList.remove('show'); summ.style.display = 'none'; summ.innerHTML = ''; if (!f) { errEl.textContent = 'Selecciona un archivo'; errEl.classList.add('show'); return; } // Lock UI btn.disabled = true; btn.querySelector('.btn-text').textContent = 'Procesando…'; btn.querySelector('.btn-spinner').style.display = 'inline-block'; const fd = new FormData(); fd.append('archivo', f, f.name); try { const res = await fetch('/api/productos/excel', { method: 'POST', body: fd, credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al procesar el archivo'); renderExcelSummary(data.resumen); // Refrescar lista de productos disponibles (para que se vean los nuevos nombres) if (currentView === 'productos' && typeof cargarProductosDisponibles === 'function') { cargarProductosDisponibles(); } } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.querySelector('.btn-text').textContent = 'Procesar archivo'; btn.querySelector('.btn-spinner').style.display = 'none'; } } function renderExcelSummary(r) { const cont = document.getElementById('excelSummary'); cont.innerHTML = ''; if (r.actualizados > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-success'; const items = (r.actualizadosList || []).slice(0, 10).map(a => `${escapeHtml(a.ref)}${a.fila ? ` (fila ${a.fila})` : ''}` ).join(''); const more = r.actualizadosList && r.actualizadosList.length > 10 ? `…y ${r.actualizadosList.length - 10} más` : ''; row.innerHTML = ` ✅ ${r.actualizados} producto(s) actualizado(s) ${items}${more} `; cont.appendChild(row); } if (r.noEncontrados > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-warning'; const items = (r.noEncontradosList || []).slice(0, 10).map(ref => `${escapeHtml(ref)} (no existe en el catálogo)` ).join(''); const more = r.noEncontradosList && r.noEncontradosList.length > 10 ? `…y ${r.noEncontradosList.length - 10} más` : ''; row.innerHTML = ` ⚠️ ${r.noEncontrados} referencia(s) no encontrada(s) — no se crearon productos nuevos ${items}${more} `; cont.appendChild(row); } if (r.errores > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-error'; const items = (r.erroresList || []).slice(0, 10).map(e => `${escapeHtml(e.ref || ('Fila ' + e.fila))} — ${escapeHtml(e.motivo)}` ).join(''); const more = r.erroresList && r.erroresList.length > 10 ? `…y ${r.erroresList.length - 10} más` : ''; row.innerHTML = ` ❌ ${r.errores} error(es) al procesar ${items}${more} `; cont.appendChild(row); } if (r.filasVacias > 0) { const row = document.createElement('div'); row.className = 'upload-summary-row is-warning'; row.innerHTML = ` ℹ️ ${r.filasVacias} fila(s) vacía(s) ignorada(s) `; cont.appendChild(row); } if (cont.children.length === 0) { cont.innerHTML = `ℹ️Nada se actualizó`; } cont.style.display = 'flex'; } function abrirAgotarProducto(ref) { if (!confirm(`¿Marcar el producto "${ref}" como agotado?\n\nSu imagen se moverá a la carpeta "agotados" y dejará de mostrarse en el catálogo público. Podrás reactivarlo después desde la pestaña "Agotados".`)) return; agotarProducto(ref); } async function agotarProducto(ref) { try { const res = await fetch(`/api/productos/${encodeURIComponent(ref)}/agotar`, { method: 'POST', credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al agotar el producto'); // Refrescar vistas relacionadas await Promise.all([ cargarProductosDisponibles(), cargarEstadisticas() ]); } catch (err) { alert('Error: ' + err.message); } } // ════════════════════════════════ // AGOTADOS (vista dedicada) // ════════════════════════════════ async function cargarAgotados() { const grid = document.getElementById('agotadosGrid'); if (!grid) return; grid.innerHTML = 'Cargando…'; try { const [resProds, resCats] = await Promise.all([ fetch('/api/productos', { credentials: 'include' }), fetch('/api/categorias', { credentials: 'include' }) ]); if (!resProds.ok) { if (resProds.status === 401 || resProds.status === 403) { mostrarLogin(); return; } throw new Error('Error al cargar productos'); } const productos = await resProds.json(); const categorias = resCats.ok ? await resCats.json() : []; cachedProductos = productos; renderAgotados(productos, categorias); } catch (err) { console.error(err); grid.innerHTML = 'Error al cargar agotados'; } } function renderAgotados(productos, categorias) { const grid = document.getElementById('agotadosGrid'); if (!grid) return; // Solo agotados: img empieza con "agotados/" const agotados = productos.filter(p => p.img && p.img.startsWith('agotados/')); if (agotados.length === 0) { grid.innerHTML = 'No hay productos agotados. Cuando un producto se agote, aparecerá aquí.'; return; } // Ordenar por ref agotados.sort((a, b) => String(a.ref).localeCompare(String(b.ref))); grid.innerHTML = agotados.map(p => { const cat = categorias.find(c => c.id === p.cat); const imgSrc = `/Imagenes/${p.img}`; return ` Agotado ${cat ? cat.icono : '🚫'} ${escapeHtml(p.ref)} ${cat ? cat.icono : '📦'} ${escapeHtml(cat ? cat.nombre : p.cat)} ↩ Reactivar 🗑 Eliminar `; }).join(''); } async function reactivarProducto(ref) { if (!confirm(`¿Reactivar el producto "${ref}"?\n\nSu imagen volverá a su categoría original y volverá a mostrarse en el catálogo público.`)) return; try { const res = await fetch(`/api/productos/${encodeURIComponent(ref)}/reactivar`, { method: 'POST', credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al reactivar'); // Refrescar vistas await Promise.all([ cargarAgotados(), cargarEstadisticas() ]); // Si la lista de productos está visible (cargada), refrescarla también const grid = document.getElementById('productosListGrid'); if (grid && currentView === 'productos') { cargarProductosDisponibles(); } } catch (err) { alert('Error: ' + err.message); } } function abrirEliminarProductoDef(ref) { deletingProductoRef = ref; document.getElementById('eliminarProdRef').textContent = ref; document.getElementById('eliminarProdError').classList.remove('show'); document.getElementById('eliminarProdError').textContent = ''; abrirModal('modalEliminarProductoDef'); } async function confirmarEliminarProductoDef() { if (!deletingProductoRef) return; const btn = document.getElementById('btnConfirmarEliminarProdDef'); const errEl = document.getElementById('eliminarProdError'); btn.disabled = true; btn.textContent = 'Eliminando…'; try { const res = await fetch(`/api/productos/${encodeURIComponent(deletingProductoRef)}`, { method: 'DELETE', credentials: 'include' }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Error al eliminar'); deletingProductoRef = null; cerrarModal('modalEliminarProductoDef'); await cargarAgotados(); cargarEstadisticas(); // Si la lista de productos está visible, refrescarla const grid = document.getElementById('productosListGrid'); if (grid && currentView === 'productos') { cargarProductosDisponibles(); } } catch (err) { errEl.textContent = err.message; errEl.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Sí, eliminar'; } } // Hook al botón de confirmación del modal de eliminación definitiva // (se ejecuta al cargar el DOM junto con el resto de setupModals) document.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('btnConfirmarEliminarProdDef'); if (btn) btn.addEventListener('click', confirmarEliminarProductoDef); });
Cargando…
Error al cargar categorías
${escapeHtml(c.ref)}
${escapeHtml(s.ref)}
${escapeHtml(i.archivo)}
${escapeHtml(e.ref)}
Error al cargar productos
${escapeHtml(a.ref)}
${escapeHtml(ref)}
${escapeHtml(e.ref || ('Fila ' + e.fila))}
Error al cargar agotados