- ${items}${more}
diff --git a/backend/package.json b/backend/package.json index a77efee..defc3ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,7 +11,8 @@ "cookie-parser": "^1.4.6", "express": "^4.19.2", "multer": "^1.4.5-lts.1", - "sharp": "^0.33.5" + "sharp": "^0.33.5", + "xlsx": "^0.18.5" }, "engines": { "node": ">=18" diff --git a/backend/server.js b/backend/server.js index ac58b21..3b37056 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,7 @@ const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); const sharp = require('sharp'); +const XLSX = require('xlsx'); const app = express(); const PORT = process.env.PORT || 3000; @@ -904,6 +905,219 @@ app.delete('/api/productos/:ref', requireProductosManagement, async (req, res) = } }); +// ════════════════════════════════ +// ASIGNAR NOMBRE + PRECIO DESDE EXCEL/CSV (admin o permiso 'productos') +// +// El archivo debe tener encabezados en la primera fila: +// A: REFERENCIA | B: DESCRIPCION | C: PRECIO +// +// Reglas: +// - El Excel/CSV NO crea productos nuevos. Solo actualiza los que ya +// existen en productos.json (match por `ref` exacto). +// - Si una celda está vacía, NO sobrescribe el valor actual. +// - Precio acepta: "50000", "$ 50.000", "50,000.00", "50.000,50", etc. +// - Si un `ref` del archivo no existe, se reporta en "noEncontrados". +// ════════════════════════════════ + +// Helper: normalizar texto para comparar headers (trim, lowercase, sin acentos) +function normHeader(s) { + return String(s || '') + .trim() + .toLowerCase() + .normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + +// Helper: parsear precio en múltiples formatos (CO/US/EU) +// "50000" → 50000 +// "$ 50.000" → 50000 (formato CO: punto = miles) +// "50,000.00" → 50000 (formato US: coma = miles, punto = decimal) +// "50.000,50" → 50000.5 (formato CO/EU: punto = miles, coma = decimal) +// " $1.234,56" → 1234.56 +// "75.500" → 75500 (heurística: 3 dígitos tras último punto = miles) +// "75.5" → 75.5 (1-2 dígitos tras último punto = decimal) +function parsePrice(value) { + if (value === null || value === undefined || value === '') return null; + let s = String(value).trim(); + if (!s) return null; + + // Quitar símbolos de moneda y espacios + s = s.replace(/[$€£¥₹\s]/g, ''); + if (!s) return null; + + const isNegative = (s.startsWith('-') || (s.startsWith('(') && s.endsWith(')'))); + s = s.replace(/^[-()]/, '').replace(/\)$/, ''); + + const lastComma = s.lastIndexOf(','); + const lastDot = s.lastIndexOf('.'); + + let normalized; + if (lastComma !== -1 && lastDot !== -1) { + // Hay ambos separadores: el ÚLTIMO es el decimal + if (lastComma > lastDot) { + // Formato CO/EU: "1.000,50" → coma es decimal + normalized = s.replace(/\./g, '').replace(',', '.'); + } else { + // Formato US: "1,000.50" → punto es decimal + normalized = s.replace(/,/g, ''); + } + } else if (lastComma !== -1) { + // Solo comas. Si la última parte tiene 1-2 dígitos, decimal; si no, miles + const parts = s.split(','); + if (parts.length === 2 && parts[1].length <= 2) { + normalized = s.replace(',', '.'); + } else { + normalized = s.replace(/,/g, ''); + } + } else if (lastDot !== -1) { + // Solo puntos. Heurística: si la parte tras el último punto tiene 3 dígitos + // (y no hay otro punto), es separador de miles (formato CO: "75.000" = 75000). + // Si tiene 1-2 dígitos, es decimal ("75.5" = 75.5). + const afterLastDot = s.slice(lastDot + 1); + if (s.split('.').length === 2 && afterLastDot.length === 3 && /^\d+$/.test(afterLastDot)) { + // Formato CO sin comas: "75.000" = 75000 + normalized = s.replace(/\./g, ''); + } else { + // Decimal: "75.5", "75.50", "1.5.5" (raro pero lo dejamos pasar) + normalized = s; + } + } else { + normalized = s; + } + + const num = parseFloat(normalized); + if (isNaN(num)) return null; + return isNegative ? -num : num; +} + +// Multer específico para el Excel (1 solo archivo, hasta 5MB) +const excelUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024, files: 1 } +}); + +app.post('/api/productos/excel', requireProductosManagement, excelUpload.single('archivo'), async (req, res) => { + const file = req.file; + if (!file) { + return res.status(400).json({ error: 'No se envió ningún archivo' }); + } + + try { + // Parsear el archivo (.xlsx o .csv). SheetJS detecta el formato por contenido. + // codepage: 65001 fuerza UTF-8 (necesario para acentos/ñ en CSV). + const wb = XLSX.read(file.buffer, { type: 'buffer', codepage: 65001 }); + const sheetName = wb.SheetNames[0]; + if (!sheetName) { + return res.status(400).json({ error: 'El archivo no tiene hojas' }); + } + const sheet = wb.Sheets[sheetName]; + // header: 1 → array de arrays. raw: false devuelve el valor FORMATEADO de la celda + // (preserva strings tal cual aparecen, ej: "45.000,50" se queda como string). + // Esto es crítico para no interpretar mal separadores de miles/decimales. + // defval: '' para celdas vacías. + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false }); + + if (rows.length === 0) { + return res.status(400).json({ error: 'El archivo está vacío' }); + } + + // Validar encabezados (case-insensitive, sin acentos) + const headersRaw = rows[0].map(h => normHeader(h)); + const expected = ['referencia', 'descripcion', 'precio']; + if ( + headersRaw.length < 3 || + headersRaw[0] !== expected[0] || + headersRaw[1] !== expected[1] || + headersRaw[2] !== expected[2] + ) { + return res.status(400).json({ + error: `Los encabezados deben ser exactamente: ${expected.join(' | ').toUpperCase()}. Encontrado: ${rows[0].map(h => String(h).trim() || '(vacío)').join(' | ').toUpperCase()}` + }); + } + + // Procesar filas + const productos = await getProductos(); + const productosByRef = new Map(productos.map(p => [String(p.ref), p])); + + const actualizados = []; + const noEncontrados = []; + const errores = []; + const filasVacias = []; + let filasConDatos = 0; + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + + // Saltar filas completamente vacías + const isEmpty = row.every(c => c === '' || c === null || c === undefined); + if (isEmpty) { + filasVacias.push(i + 1); // 1-indexed para el usuario + continue; + } + + const refRaw = row[0] != null ? String(row[0]).trim() : ''; + const descRaw = row[1] != null ? String(row[1]).trim() : ''; + const precioRaw = row[2]; + + if (!refRaw) { + errores.push({ fila: i + 1, motivo: 'REFERENCIA vacía' }); + continue; + } + + const producto = productosByRef.get(refRaw); + if (!producto) { + noEncontrados.push(refRaw); + continue; + } + + // Actualizar SOLO si la celda tiene valor + let changed = false; + if (descRaw) { + producto.nombre = descRaw; + changed = true; + } + if (precioRaw !== '' && precioRaw !== null && precioRaw !== undefined) { + // XLSX puede entregar números o strings según formato de celda + let precio; + if (typeof precioRaw === 'number') { + precio = precioRaw; + } else { + precio = parsePrice(precioRaw); + } + if (precio === null || isNaN(precio)) { + errores.push({ fila: i + 1, ref: refRaw, motivo: `Precio inválido: "${precioRaw}"` }); + continue; + } + producto.precio = precio; + changed = true; + } + + if (changed) { + actualizados.push({ ref: refRaw, fila: i + 1 }); + filasConDatos++; + } + } + + await saveProductos(productos); + + res.json({ + ok: true, + resumen: { + actualizados: actualizados.length, + actualizadosList: actualizados, + noEncontrados: noEncontrados.length, + noEncontradosList: noEncontrados, + errores: errores.length, + erroresList: errores, + filasVacias: filasVacias.length, + filasVaciasList: filasVacias + } + }); + } catch (err) { + console.error('Excel upload error:', err); + res.status(500).json({ error: 'Error al procesar el archivo: ' + err.message }); + } +}); + // Multer error handler (tamaño excedido, etc.) app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { @@ -931,9 +1145,75 @@ app.get('/administrativo/', (req, res) => { res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html')); }); +// ════════════════════════════════ +// LIMPIEZA DE SESIONES A LAS 00:00 (America/Bogota) +// Vacía sesiones.json todos los días a medianoche hora Colombia. +// Cuando los usuarios abran la app después de las 00:00, tendrán +// que volver a loguearse (sus cookies quedaron firmadas pero el +// token ya no existe en sesiones.json → 401 → redirige a /login). +// ════════════════════════════════ +function msUntilNextMidnightBogota() { + // Obtener la hora actual en America/Bogota (UTC-5, sin DST) + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Bogota', + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + const parts = {}; + for (const part of formatter.formatToParts(new Date())) { + if (part.type !== 'literal') parts[part.type] = part.value; + } + // En formato 24h, medianoche puede aparecer como "24" en algunos sistemas + const hour = parseInt(parts.hour === '24' ? '0' : parts.hour, 10); + const minute = parseInt(parts.minute, 10); + const second = parseInt(parts.second, 10); + const msPassedToday = (hour * 3600 + minute * 60 + second) * 1000; + return 24 * 3600 * 1000 - msPassedToday; +} + +async function runSessionCleanup(logPrefix = '[cleanup]') { + try { + const sesiones = await getSesiones(); + if (sesiones.length === 0) { + console.log(`${logPrefix} sesiones.json ya está vacío, nada que limpiar`); + return { removed: 0 }; + } + await saveSesiones([]); + console.log(`${logPrefix} ✅ ${sesiones.length} sesión(es) cerrada(s) a las 00:00 CO`); + return { removed: sesiones.length }; + } catch (err) { + console.error(`${logPrefix} Error limpiando sesiones:`, err.message); + return { removed: 0, error: err.message }; + } +} + +function scheduleSessionCleanup() { + const ms = msUntilNextMidnightBogota(); + const hours = (ms / 1000 / 60 / 60).toFixed(2); + console.log(`[cleanup] Próxima limpieza de sesiones en ${hours}h (00:00 America/Bogota)`); + + setTimeout(() => { + runSessionCleanup(); + // Después de la primera ejecución, repetir cada 24h + setInterval(() => runSessionCleanup(), 24 * 60 * 60 * 1000); + }, ms); +} + +// ════════════════════════════════ +// ENDPOINT MANUAL (admin) — útil para emergencias o testing +// Cierra TODAS las sesiones inmediatamente. +// ════════════════════════════════ +app.post('/api/admin/cleanup-sessions', requireAdmin, async (req, res) => { + const result = await runSessionCleanup('[manual]'); + res.json({ ok: true, ...result }); +}); + // ════════════════════════════════ // START // ════════════════════════════════ app.listen(PORT, () => { console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`); + scheduleSessionCleanup(); }); diff --git a/data/productos.json b/data/productos.json index 27594f1..d46808f 100644 --- a/data/productos.json +++ b/data/productos.json @@ -3,37 +3,47 @@ "id": "AF100-124", "ref": "AF100-124", "cat": "bolsos", - "img": "agotados/AF100-124.webp" + "img": "bolsos/AF100-124.webp", + "nombre": "Bolso de cuero negro estilo clásico", + "precio": 50000 }, { "id": "AF100-128", "ref": "AF100-128", "cat": "bolsos", - "img": "bolsos/AF100-128.webp" + "img": "bolsos/AF100-128.webp", + "nombre": "Bolso de mano elegante con detalles dorados", + "precio": 75000 }, { "id": "AF100-129", "ref": "AF100-129", "cat": "bolsos", - "img": "bolsos/AF100-129.webp" + "img": "bolsos/AF100-129.webp", + "nombre": "Bolso deportivo resistente al agua", + "precio": 45000.5 }, { "id": "AF100-159", "ref": "AF100-159", "cat": "bolsos", - "img": "bolsos/AF100-159.webp" + "img": "bolsos/AF100-159.webp", + "precio": 85000 }, { "id": "AF100-171", "ref": "AF100-171", "cat": "bolsos", - "img": "bolsos/AF100-171.webp" + "img": "bolsos/AF100-171.webp", + "nombre": "Mochila urbana minimalista" }, { "id": "AF100-174", "ref": "AF100-174", "cat": "bolsos", - "img": "bolsos/AF100-174.webp" + "img": "bolsos/AF100-174.webp", + "nombre": "Set de bolsos combinados (3 piezas)", + "precio": 120000 }, { "id": "AF9173", diff --git a/data/sesiones.json b/data/sesiones.json index 1f55d6c..a2471c6 100644 --- a/data/sesiones.json +++ b/data/sesiones.json @@ -1,104 +1,8 @@ [ { - "token": "ccf9611f3e8e338992b1d0428049640aef7c89e6c47efd2d1d5fc94cb299aba5", + "token": "cf5d634b0f9ad09bfcc6370f57c45d2049b7336af476107cea3b5c1617df29e0", "usuario": "admin", "rol": "admin", - "createdAt": 1780505178562 - }, - { - "token": "1066042a594d4fc5560fef8a52c3166ac204b6edaee5beffe9103af6b0aa536b", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780506861676 - }, - { - "token": "5685227e864f97fa81079ee2f6f105c97889c6d9555de06bfd3934f401bda2e1", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780506937778 - }, - { - "token": "567193982f539690ded8267e45524509eb178189e50ec31791008c87e78e62d0", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780512239821 - }, - { - "token": "88a9ec8328afaf51d197c09e20c6fd2b76b79c038197a15fcdb0ee8a069bd6d3", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780512593175 - }, - { - "token": "a50369058a82a4303d4d47ce06662ec6b957dfbdc48a5e7032876c0d2d7739bb", - "usuario": "editor_noprod", - "rol": "editor", - "createdAt": 1780512670584 - }, - { - "token": "2161f0b1957b4ca09d214f2e4e2cb925a34a734d1410e86cc75d2d144b8b4878", - "usuario": "editor_prod", - "rol": "editor", - "createdAt": 1780512670625 - }, - { - "token": "6106cd70b5dffa613e9acd5bbd26c1359187ba51138a969712cdb9e7cf3e6b97", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780512723692 - }, - { - "token": "765b16fc212ec002679a84ae78e12185e60df7882e46cdfa16ed1629736ee1f1", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780513577239 - }, - { - "token": "e9e68a32b6b0f0a4a701428184ea55a5653ffaab35aed0a4778507db79883caa", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780513919289 - }, - { - "token": "268c41d4b2993e01fe7f1264464e61eef9c4f7fb987c7d36ae202341cf0e0eaf", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780513939678 - }, - { - "token": "981705570c9fd6a956418d6d38f733e267938e99b85f09ac37aa14331cfa8a63", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780514125713 - }, - { - "token": "0e36ecd59edd1cddc4b7078b2f3fdad2d40a6220060b2778a8340bcfdef425b6", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780514195748 - }, - { - "token": "334527a1d0c63bbd73185952f89e962c90a3f37fb93ea3cd76ac528ed12573f5", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780516032214 - }, - { - "token": "afc0f28ed9e2a73685d2f3bd3845a92184b2cc0c2b34743f8f7675e972cb2010", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780516109693 - }, - { - "token": "a07b896abdd2869d699b224d52a1e885317cdeb65ad4d136038966a4c7a1f4f8", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780521156938 - }, - { - "token": "2599baf2a3ddd4e3d0b5839c165fd73722ea4581d8d136ce7fab8f3e216b5158", - "usuario": "admin", - "rol": "admin", - "createdAt": 1780522237750 + "createdAt": 1780718070269 } ] diff --git a/frontend/administrativo/admin.css b/frontend/administrativo/admin.css index a377226..e5dd21c 100644 --- a/frontend/administrativo/admin.css +++ b/frontend/administrativo/admin.css @@ -1816,3 +1816,65 @@ select { padding: 8px 10px; } } + +/* ════════════════════════════════ + EXCEL/CSV — sección de asignar nombre + precio + ════════════════════════════════ */ +.excel-form { + display: flex; + flex-direction: column; + gap: 18px; + margin-top: 18px; +} + +/* Excel/CSV — hint compacto de formato */ +.excel-format-hint { + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 10px 14px; + margin: 0 0 14px 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 12px; + font-size: 0.85rem; + line-height: 1.4; + color: var(--ink-soft); +} +.excel-format-cols { + font-family: 'SF Mono', Menlo, monospace; + font-size: 0.82rem; + color: var(--ink); + background: var(--white); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 4px 10px; + display: inline-flex; + align-items: center; + gap: 4px; +} +.excel-format-cols strong { + color: var(--violet-deep); + font-weight: 700; + font-family: inherit; +} +.excel-format-cols .sep { + color: var(--ink-mute); + font-weight: 400; + margin: 0 2px; +} +.excel-format-meta { + color: var(--ink-mute); + font-size: 0.78rem; +} + +.excel-summary { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: var(--radius-md); +} diff --git a/frontend/administrativo/admin.js b/frontend/administrativo/admin.js index 89e32ef..b310cb9 100644 --- a/frontend/administrativo/admin.js +++ b/frontend/administrativo/admin.js @@ -46,6 +46,7 @@ document.addEventListener('DOMContentLoaded', async () => { setupCategoriasModals(); setupProductosUpload(); setupProductosSearch(); + setupExcelUpload(); await checkSession(); }); @@ -1327,6 +1328,155 @@ function setupProductosSearch() { }); } +// ════════════════════════════════ +// EXCEL/CSV — asignar nombre + precio por referencia +// ════════════════════════════════ +function setupExcelUpload() { + const input = document.getElementById('excelArchivo'); + const hint = document.getElementById('excelArchivoHint'); + const form = document.getElementById('formExcelUpload'); + const btn = document.getElementById('btnProcesarExcel'); + if (!input || !form) return; + + input.addEventListener('change', () => { + const f = input.files && input.files[0]; + if (f) { + const sizeKB = (f.size / 1024).toFixed(1); + hint.textContent = `📄 ${f.name} · ${sizeKB} KB`; + } else { + hint.textContent = 'Ningún archivo seleccionado'; + } + btn.disabled = !f; + }); + + form.addEventListener('submit', submitExcelUpload); +} + +async function submitExcelUpload(e) { + e.preventDefault(); + const input = document.getElementById('excelArchivo'); + const btn = document.getElementById('btnProcesarExcel'); + const errEl = document.getElementById('excelError'); + const summ = document.getElementById('excelSummary'); + const f = input.files && input.files[0]; + + errEl.textContent = ''; + errEl.classList.remove('show'); + summ.style.display = 'none'; + summ.innerHTML = ''; + + if (!f) { + errEl.textContent = 'Selecciona un archivo'; + errEl.classList.add('show'); + return; + } + + // Lock UI + btn.disabled = true; + btn.querySelector('.btn-text').textContent = 'Procesando…'; + btn.querySelector('.btn-spinner').style.display = 'inline-block'; + + const fd = new FormData(); + fd.append('archivo', f, f.name); + + try { + const res = await fetch('/api/productos/excel', { method: 'POST', body: fd, credentials: 'include' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al procesar el archivo'); + + renderExcelSummary(data.resumen); + + // Refrescar lista de productos disponibles (para que se vean los nuevos nombres) + if (currentView === 'productos' && typeof cargarProductosDisponibles === 'function') { + cargarProductosDisponibles(); + } + } catch (err) { + errEl.textContent = err.message; + errEl.classList.add('show'); + } finally { + btn.disabled = false; + btn.querySelector('.btn-text').textContent = 'Procesar archivo'; + btn.querySelector('.btn-spinner').style.display = 'none'; + } +} + +function renderExcelSummary(r) { + const cont = document.getElementById('excelSummary'); + cont.innerHTML = ''; + + if (r.actualizados > 0) { + const row = document.createElement('div'); + row.className = 'upload-summary-row is-success'; + const items = (r.actualizadosList || []).slice(0, 10).map(a => + `
${escapeHtml(a.ref)}${a.fila ? ` (fila ${a.fila})` : ''}${escapeHtml(ref)} (no existe en el catálogo)${escapeHtml(e.ref || ('Fila ' + e.fila))} — ${escapeHtml(e.motivo)}+ + A REFERENCIA + | + B DESCRIPCION + | + C PRECIO + + +
+ + +