Excel y sesiones.json a las 00
This commit is contained in:
@@ -11,7 +11,8 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const path = require('path');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
const XLSX = require('xlsx');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
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.)
|
// Multer error handler (tamaño excedido, etc.)
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
@@ -931,9 +1145,75 @@ app.get('/administrativo/', (req, res) => {
|
|||||||
res.sendFile(path.join(FRONTEND_DIR, 'administrativo', 'index.html'));
|
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
|
// START
|
||||||
// ════════════════════════════════
|
// ════════════════════════════════
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`);
|
console.log(`∞ Infinity server corriendo en http://localhost:${PORT}`);
|
||||||
|
scheduleSessionCleanup();
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-6
@@ -3,37 +3,47 @@
|
|||||||
"id": "AF100-124",
|
"id": "AF100-124",
|
||||||
"ref": "AF100-124",
|
"ref": "AF100-124",
|
||||||
"cat": "bolsos",
|
"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",
|
"id": "AF100-128",
|
||||||
"ref": "AF100-128",
|
"ref": "AF100-128",
|
||||||
"cat": "bolsos",
|
"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",
|
"id": "AF100-129",
|
||||||
"ref": "AF100-129",
|
"ref": "AF100-129",
|
||||||
"cat": "bolsos",
|
"cat": "bolsos",
|
||||||
"img": "bolsos/AF100-129.webp"
|
"img": "bolsos/AF100-129.webp",
|
||||||
|
"nombre": "Bolso deportivo resistente al agua",
|
||||||
|
"precio": 45000.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-159",
|
"id": "AF100-159",
|
||||||
"ref": "AF100-159",
|
"ref": "AF100-159",
|
||||||
"cat": "bolsos",
|
"cat": "bolsos",
|
||||||
"img": "bolsos/AF100-159.webp"
|
"img": "bolsos/AF100-159.webp",
|
||||||
|
"precio": 85000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-171",
|
"id": "AF100-171",
|
||||||
"ref": "AF100-171",
|
"ref": "AF100-171",
|
||||||
"cat": "bolsos",
|
"cat": "bolsos",
|
||||||
"img": "bolsos/AF100-171.webp"
|
"img": "bolsos/AF100-171.webp",
|
||||||
|
"nombre": "Mochila urbana minimalista"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF100-174",
|
"id": "AF100-174",
|
||||||
"ref": "AF100-174",
|
"ref": "AF100-174",
|
||||||
"cat": "bolsos",
|
"cat": "bolsos",
|
||||||
"img": "bolsos/AF100-174.webp"
|
"img": "bolsos/AF100-174.webp",
|
||||||
|
"nombre": "Set de bolsos combinados (3 piezas)",
|
||||||
|
"precio": 120000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "AF9173",
|
"id": "AF9173",
|
||||||
|
|||||||
+2
-98
@@ -1,104 +1,8 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"token": "ccf9611f3e8e338992b1d0428049640aef7c89e6c47efd2d1d5fc94cb299aba5",
|
"token": "cf5d634b0f9ad09bfcc6370f57c45d2049b7336af476107cea3b5c1617df29e0",
|
||||||
"usuario": "admin",
|
"usuario": "admin",
|
||||||
"rol": "admin",
|
"rol": "admin",
|
||||||
"createdAt": 1780505178562
|
"createdAt": 1780718070269
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1816,3 +1816,65 @@ select {
|
|||||||
padding: 8px 10px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setupCategoriasModals();
|
setupCategoriasModals();
|
||||||
setupProductosUpload();
|
setupProductosUpload();
|
||||||
setupProductosSearch();
|
setupProductosSearch();
|
||||||
|
setupExcelUpload();
|
||||||
|
|
||||||
await checkSession();
|
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 =>
|
||||||
|
`<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) {
|
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;
|
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);
|
agotarProducto(ref);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="admin.css?v=4">
|
<link rel="stylesheet" href="admin.css?v=5">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -164,6 +164,42 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sub-sección: Asignar nombre + precio desde Excel/CSV -->
|
||||||
|
<div class="view-subsection" style="margin-top:28px">
|
||||||
|
<h2 class="subsection-title">📊 Asignar descripción y precio (Excel/CSV)</h2>
|
||||||
|
<div class="view-content-card">
|
||||||
|
<p class="excel-format-hint">
|
||||||
|
<span class="excel-format-cols">
|
||||||
|
<strong>A</strong> REFERENCIA
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<strong>B</strong> DESCRIPCION
|
||||||
|
<span class="sep">|</span>
|
||||||
|
<strong>C</strong> PRECIO
|
||||||
|
</span>
|
||||||
|
<span class="excel-format-meta">.xlsx / .csv · encabezados en la primera fila · celdas vacías no sobrescriben</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="formExcelUpload" class="excel-form" autocomplete="off">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="excelArchivo">Archivo Excel/CSV</label>
|
||||||
|
<input type="file" id="excelArchivo" accept=".xlsx,.xls,.csv" required>
|
||||||
|
<span class="form-hint" id="excelArchivoHint">Ningún archivo seleccionado</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="excel-summary" id="excelSummary" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="form-error" id="excelError" role="alert"></div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary" id="btnProcesarExcel" disabled>
|
||||||
|
<span class="btn-text">Procesar archivo</span>
|
||||||
|
<span class="btn-spinner" style="display:none">⏳</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sub-sección: Lista de productos disponibles -->
|
<!-- Sub-sección: Lista de productos disponibles -->
|
||||||
<div class="view-subsection">
|
<div class="view-subsection">
|
||||||
<div class="subsection-header">
|
<div class="subsection-header">
|
||||||
@@ -464,6 +500,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="admin.js?v=4"></script>
|
<script src="admin.js?v=5"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user