Excel y sesiones.json a las 00

This commit is contained in:
2026-06-05 22:56:35 -05:00
parent aa5e85ae94
commit 984f2f103d
7 changed files with 550 additions and 107 deletions
+2 -1
View File
@@ -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"
+280
View File
@@ -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();
});
+16 -6
View File
@@ -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",
+2 -98
View File
@@ -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
}
]
+62
View File
@@ -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);
}
+150
View File
@@ -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 =>
`<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);
+38 -2
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="stylesheet" href="admin.css?v=4">
<link rel="stylesheet" href="admin.css?v=5">
</head>
<body>
@@ -164,6 +164,42 @@
</form>
</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 -->
<div class="view-subsection">
<div class="subsection-header">
@@ -464,6 +500,6 @@
</div>
</div>
<script src="admin.js?v=4"></script>
<script src="admin.js?v=5"></script>
</body>
</html>