1644 lines
57 KiB
JavaScript
1644 lines
57 KiB
JavaScript
/* ════════════════════════════════
|
||
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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[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);
|
||
});
|