Files
CATALOGO_INFINITY/frontend/administrativo/admin.js
T

1644 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ════════════════════════════════
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: 'categorias', label: 'Categorías', icon: '🗂️' },
{ id: 'usuarios', label: 'Usuarios', icon: '👥' }
];
const ROLES = {
admin: 'Administrador',
editor: 'Editor'
};
// 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();
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();
// Reset a la vista dashboard
cambiarVista('dashboard');
cargarEstadisticas();
}
function cambiarVista(view) {
// Check defensivo: si el usuario no tiene permiso para esta vista, redirige a dashboard
if (currentUser && view !== 'dashboard') {
const esAdmin = currentUser.rol === 'admin';
const permisos = currentUser.permisos || [];
if (!esAdmin && !permisos.includes(view)) {
view = 'dashboard';
}
}
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();
}
}
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[]
// ════════════════════════════════
function aplicarPermisos() {
if (!currentUser) return;
const esAdmin = currentUser.rol === 'admin';
const permisos = currentUser.permisos || [];
const puedeGestionarUsuarios = esAdmin || permisos.includes('usuarios');
// Mostrar/ocultar cada nav-item según permisos
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
const view = item.dataset.view;
let visible = false;
if (view === 'dashboard') {
visible = true; // Dashboard siempre visible
} else if (esAdmin) {
visible = true; // Admin ve todo
} else {
visible = permisos.includes(view); // Editor: solo sus permisos
}
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);
});
});
}
// ════════════════════════════════
// PERMISOS EN MODALES — render y lectura
// ════════════════════════════════
function renderPermisos(containerId, checkedPerms = []) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = TABS.map(t => `
<label class="permission-check">
<input type="checkbox" value="${t.id}" ${checkedPerms.includes(t.id) ? 'checked' : ''}>
<span class="permission-icon">${t.icon}</span>
<span class="permission-label">${t.label}</span>
</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 <select>
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 = '<tr><td colspan="3" style="text-align:center;color:var(--ink-mute);">Cargando…</td></tr>';
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 = '<tr><td colspan="3" style="text-align:center;color:var(--danger);">Error al cargar usuarios</td></tr>';
}
}
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 = `<tr><td colspan="${puedeGestionar ? 3 : 2}" style="text-align:center;color:var(--ink-mute);padding:30px;">No hay usuarios aún</td></tr>`;
return;
}
tbody.innerHTML = usuarios.map(u => {
const isYou = u.usuario === currentUser.usuario;
return `
<tr>
<td>
<strong>${escapeHtml(u.usuario)}</strong>
${isYou ? '<span class="you-badge">tú</span>' : ''}
</td>
<td>
<span class="rol-badge rol-${u.rol}">${ROLES[u.rol] || u.rol}</span>
</td>
${puedeGestionar ? `
<td class="users-actions">
<button class="btn-icon" onclick="abrirEditarUsuario('${escapeAttr(u.usuario)}')" title="Editar" type="button">✏️</button>
${isYou
? '<span class="btn-icon-spacer"></span>'
: `<button class="btn-icon btn-icon-danger" onclick="abrirEliminarUsuario('${escapeAttr(u.usuario)}')" title="Eliminar" type="button">🗑️</button>`}
</td>
` : ''}
</tr>
`;
}).join('');
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, m => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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 = '<p style="text-align:center;color:var(--ink-mute);padding:30px;">Cargando…</p>';
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 = '<p style="text-align:center;color:var(--danger);padding:30px;">Error al cargar categorías</p>';
}
}
function renderCategorias(categorias, productos) {
const grid = document.getElementById('categoriasGrid');
if (categorias.length === 0) {
grid.innerHTML = '<div class="categorias-empty">No hay categorías. Crea la primera con el botón "+ Crear categoría".</div>';
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';
return `
<div class="categoria-card ${isSistema ? 'categoria-sistema' : ''}">
${isSistema ? '<span class="categoria-sistema-badge">Sistema</span>' : ''}
<div class="categoria-icon">${c.icono || '📦'}</div>
<div class="categoria-nombre">${escapeHtml(c.nombre)}</div>
<div class="categoria-id">${escapeHtml(c.id)}</div>
<div class="categoria-count">${count} item${count === 1 ? '' : 's'}</div>
${!isSistema ? `
<div class="categoria-actions">
<button class="btn-icon" onclick="abrirEditarCategoria('${escapeAttr(c.id)}')" title="Editar" type="button">✏️</button>
<button class="btn-icon btn-icon-danger" onclick="abrirEliminarCategoria('${escapeAttr(c.id)}','${escapeAttr(c.nombre)}')" title="Eliminar" type="button">🗑️</button>
</div>
` : ''}
</div>
`;
}).join('');
}
function renderIconPicker(selectedIcon) {
const picker = document.getElementById('iconPicker');
picker.innerHTML = CAT_ICONS.map(icon => `
<button type="button" class="icon-option ${icon === selectedIcon ? 'selected' : ''}" data-icon="${icon}">${icon}</button>
`).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);
}
// ════════════════════════════════
// 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');
const seleccionPrevia = select.value;
select.innerHTML = '<option value="">— Selecciona una categoría —</option>';
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;
console.log(`[upload] Categorías cargadas: ${categorias.filter(c => c.id !== SIN_CAT_ID).length}`);
} catch (err) {
console.error('[upload] Error cargando categorías:', err);
// Fallback visible para el usuario
const error = document.getElementById('uploadError');
if (error) error.textContent = 'No se pudieron cargar las categorías. Recarga la página.';
}
}
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');
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();
});
select.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 = `<strong>${escapeHtml(item.ref)}</strong><span>1 producto</span>`;
} else {
info.innerHTML = `<strong>${escapeHtml(item.file.name)}</strong><span>${escapeHtml(item.motivo)}</span>`;
}
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 select = document.getElementById('uploadCategoria');
const validos = pendingFiles.filter(p => p.isValid).length;
const hayCat = !!select.value;
btn.disabled = !(hayCat && validos > 0);
}
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 fd = new FormData();
fd.append('categoria', select.value);
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 = `
<span class="icon">✅</span>
<div class="text">
<strong>${r.creados} producto(s) creado(s)</strong>
<ul>${r.creadosList.map(c => `<li><code>${escapeHtml(c.ref)}</code></li>`).join('')}</ul>
</div>
`;
cont.appendChild(row);
}
if (r.saltados > 0) {
const row = document.createElement('div');
row.className = 'upload-summary-row is-warning';
row.innerHTML = `
<span class="icon">⏭️</span>
<div class="text">
<strong>${r.saltados} referencia(s) ya existían — no se sobrescribieron</strong>
<ul>${r.saltadosList.map(s => `<li><code>${escapeHtml(s.ref)}</code> — ${escapeHtml(s.motivo)}</li>`).join('')}</ul>
</div>
`;
cont.appendChild(row);
}
if (r.ignorados > 0) {
const row = document.createElement('div');
row.className = 'upload-summary-row is-warning';
row.innerHTML = `
<span class="icon">⚠️</span>
<div class="text">
<strong>${r.ignorados} archivo(s) con nombre inválido — no se procesaron</strong>
<ul>${r.ignoradosList.map(i => `<li><code>${escapeHtml(i.archivo)}</code> — ${escapeHtml(i.motivo)}</li>`).join('')}</ul>
</div>
`;
cont.appendChild(row);
}
if (r.errores > 0) {
const row = document.createElement('div');
row.className = 'upload-summary-row is-error';
row.innerHTML = `
<span class="icon">❌</span>
<div class="text">
<strong>${r.errores} error(es) al procesar</strong>
<ul>${r.erroresList.map(e => `<li><code>${escapeHtml(e.ref)}</code> — ${escapeHtml(e.motivo)}</li>`).join('')}</ul>
</div>
`;
cont.appendChild(row);
}
if (cont.children.length === 0) {
cont.innerHTML = `<div class="upload-summary-row is-warning"><span class="icon">️</span><div class="text"><strong>Nada se subió</strong></div></div>`;
}
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 = '<p style="text-align:center;color:var(--ink-mute);padding:24px;grid-column:1/-1;">Cargando…</p>';
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 = '<p style="text-align:center;color:var(--danger);padding:24px;grid-column:1/-1;">Error al cargar productos</p>';
}
}
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 = '<div class="productos-empty">No hay productos disponibles aún. Sube fotos en el formulario de arriba.</div>';
return;
}
if (disponibles.length === 0) {
grid.innerHTML = `<div class="productos-empty">No se encontraron productos con la referencia "<strong>${escapeHtml(busquedaProductos)}</strong>".</div>`;
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 `
<div class="producto-item">
<div class="producto-thumb-wrap">
<img class="producto-thumb" src="${escapeHtml(imgSrc)}" alt="${escapeHtml(p.ref)}" loading="lazy"
onerror="this.classList.add('is-broken')">
<span class="producto-thumb-fallback">${icon}</span>
</div>
<div class="producto-info">
<div class="producto-ref">${escapeHtml(p.ref)}</div>
<div class="producto-cat-tag">${icon} ${escapeHtml(cat ? cat.nombre : p.cat)}</div>
</div>
<div class="producto-actions">
<button class="btn-icon btn-icon-danger" onclick="abrirAgotarProducto('${escapeAttr(p.ref)}')" title="Marcar como agotado" type="button">🚫</button>
</div>
</div>
`;
}).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 =>
`<li><code>${escapeHtml(a.ref)}</code>${a.fila ? ` <span style="opacity:0.7">(fila ${a.fila})</span>` : ''}</li>`
).join('');
const more = r.actualizadosList && r.actualizadosList.length > 10
? `<li style="opacity:0.7">…y ${r.actualizadosList.length - 10} más</li>` : '';
row.innerHTML = `
<span class="icon">✅</span>
<div class="text">
<strong>${r.actualizados} producto(s) actualizado(s)</strong>
<ul>${items}${more}</ul>
</div>
`;
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 =>
`<li><code>${escapeHtml(ref)}</code> <span style="opacity:0.7">(no existe en el catálogo)</span></li>`
).join('');
const more = r.noEncontradosList && r.noEncontradosList.length > 10
? `<li style="opacity:0.7">…y ${r.noEncontradosList.length - 10} más</li>` : '';
row.innerHTML = `
<span class="icon">⚠️</span>
<div class="text">
<strong>${r.noEncontrados} referencia(s) no encontrada(s) — no se crearon productos nuevos</strong>
<ul>${items}${more}</ul>
</div>
`;
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 =>
`<li><code>${escapeHtml(e.ref || ('Fila ' + e.fila))}</code> — ${escapeHtml(e.motivo)}</li>`
).join('');
const more = r.erroresList && r.erroresList.length > 10
? `<li style="opacity:0.7">…y ${r.erroresList.length - 10} más</li>` : '';
row.innerHTML = `
<span class="icon">❌</span>
<div class="text">
<strong>${r.errores} error(es) al procesar</strong>
<ul>${items}${more}</ul>
</div>
`;
cont.appendChild(row);
}
if (r.filasVacias > 0) {
const row = document.createElement('div');
row.className = 'upload-summary-row is-warning';
row.innerHTML = `
<span class="icon">️</span>
<div class="text">
<strong>${r.filasVacias} fila(s) vacía(s) ignorada(s)</strong>
</div>
`;
cont.appendChild(row);
}
if (cont.children.length === 0) {
cont.innerHTML = `<div class="upload-summary-row is-warning"><span class="icon">️</span><div class="text"><strong>Nada se actualizó</strong></div></div>`;
}
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 = '<p style="text-align:center;color:var(--ink-mute);padding:24px;grid-column:1/-1;">Cargando…</p>';
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 = '<p style="text-align:center;color:var(--danger);padding:24px;grid-column:1/-1;">Error al cargar agotados</p>';
}
}
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 = '<div class="agotados-empty">No hay productos agotados. Cuando un producto se agote, aparecerá aquí.</div>';
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 `
<div class="agotado-card">
<div class="agotado-img-wrap">
<span class="agotado-badge">Agotado</span>
<img class="agotado-img" src="${escapeHtml(imgSrc)}" alt="${escapeHtml(p.ref)}" loading="lazy"
onerror="this.remove(); this.parentElement.classList.add('img-fallback');">
<span class="agotado-fallback">${cat ? cat.icono : '🚫'}</span>
</div>
<div class="agotado-info">
<div class="agotado-ref">${escapeHtml(p.ref)}</div>
<div class="agotado-cat">${cat ? cat.icono : '📦'} ${escapeHtml(cat ? cat.nombre : p.cat)}</div>
</div>
<div class="agotado-actions">
<button class="btn btn-ghost btn-sm" onclick="reactivarProducto('${escapeAttr(p.ref)}')" type="button" title="Volver a disponible">↩ Reactivar</button>
<button class="btn btn-danger btn-sm" onclick="abrirEliminarProductoDef('${escapeAttr(p.ref)}')" type="button" title="Eliminar definitivamente">🗑 Eliminar</button>
</div>
</div>
`;
}).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);
});