Files
Autoparts-DB/dashboard/bodega.js
consultoria-as 340d2fcef8 feat: add bodega dashboard — column mapping, inventory upload, listing
Three-tab panel for warehouse operators:
- Column mapping configuration (flexible CSV/Excel field mapping)
- File upload with drag-and-drop, progress tracking, error reporting
- Searchable paginated inventory view with clear-all option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:58 +00:00

523 lines
20 KiB
JavaScript

/**
* bodega.js — Warehouse (Bodega) dashboard for Nexus Autoparts
* Tabs: Mapeo de Columnas | Subir Inventario | Mi Inventario
*/
(function () {
'use strict';
var API = '';
var selectedFile = null;
var invPage = 1;
var invQuery = '';
// ================================================================
// Auth helpers
// ================================================================
function getToken() {
return localStorage.getItem('access_token') || '';
}
function getRole() {
var token = getToken();
if (!token) return null;
try {
var payload = JSON.parse(atob(token.split('.')[1]));
return payload.role || null;
} catch (e) {
return null;
}
}
function authHeaders(extra) {
var h = { 'Authorization': 'Bearer ' + getToken() };
if (extra) {
for (var k in extra) { h[k] = extra[k]; }
}
return h;
}
function checkAuth() {
var token = getToken();
var role = getRole();
if (!token || (role !== 'BODEGA' && role !== 'ADMIN')) {
window.location.href = '/login.html';
return false;
}
return true;
}
function tryRefreshToken() {
var refresh = localStorage.getItem('refresh_token');
if (!refresh) return Promise.reject(new Error('No refresh token'));
return fetch(API + '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refresh })
}).then(function (r) {
if (!r.ok) throw new Error('Refresh failed');
return r.json();
}).then(function (data) {
if (data.access_token) {
localStorage.setItem('access_token', data.access_token);
}
return data;
});
}
// ================================================================
// API helper with 401 retry
// ================================================================
function api(path, opts) {
opts = opts || {};
if (!opts.headers) opts.headers = {};
opts.headers['Authorization'] = 'Bearer ' + getToken();
return fetch(API + path, opts).then(function (r) {
if (r.status === 401) {
return tryRefreshToken().then(function () {
opts.headers['Authorization'] = 'Bearer ' + getToken();
return fetch(API + path, opts);
}).then(function (r2) {
if (!r2.ok) return r2.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r2.json();
});
}
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
// ================================================================
// Utilities
// ================================================================
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function toast(msg, type) {
var container = document.getElementById('toast-container');
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
container.appendChild(el);
setTimeout(function () { el.remove(); }, 3500);
}
function fmtDate(s) {
if (!s) return '—';
var d = new Date(s);
return d.toLocaleDateString('es-MX', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
function fmtPrice(v) {
var n = parseFloat(v);
if (isNaN(n)) return '—';
return '$' + n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function statusBadge(status) {
var map = {
'completed': { cls: 'badge-success', label: 'Completado' },
'success': { cls: 'badge-success', label: 'Completado' },
'error': { cls: 'badge-error', label: 'Error' },
'failed': { cls: 'badge-error', label: 'Fallido' },
'pending': { cls: 'badge-pending', label: 'Pendiente' },
'processing': { cls: 'badge-processing', label: 'Procesando' }
};
var info = map[(status || '').toLowerCase()] || { cls: 'badge-pending', label: status || 'Desconocido' };
return '<span class="badge ' + info.cls + '">' + esc(info.label) + '</span>';
}
// ================================================================
// Tab Switching
// ================================================================
document.querySelectorAll('.bodega-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.bodega-tab').forEach(function (t) { t.classList.remove('active'); });
document.querySelectorAll('.bodega-section').forEach(function (s) { s.classList.remove('active'); });
tab.classList.add('active');
var section = document.getElementById('section-' + tab.getAttribute('data-tab'));
if (section) section.classList.add('active');
// Load data when switching tabs
var target = tab.getAttribute('data-tab');
if (target === 'subir') loadUploadHistory();
if (target === 'inventario') loadInventory();
});
});
// ================================================================
// TAB 1: Mapeo de Columnas
// ================================================================
function loadMapping() {
api('/api/inventory/mapping').then(function (data) {
if (data.part_number) document.getElementById('map-part-number').value = data.part_number;
if (data.price) document.getElementById('map-price').value = data.price;
if (data.stock) document.getElementById('map-stock').value = data.stock;
if (data.location) document.getElementById('map-location').value = data.location;
}).catch(function () {
// No mapping yet — fields stay empty
});
}
document.getElementById('btn-save-mapping').addEventListener('click', function () {
var partNumber = document.getElementById('map-part-number').value.trim();
var price = document.getElementById('map-price').value.trim();
var stock = document.getElementById('map-stock').value.trim();
var location = document.getElementById('map-location').value.trim();
var statusEl = document.getElementById('mapping-status');
if (!partNumber || !price || !stock) {
statusEl.textContent = 'Completa los campos obligatorios.';
statusEl.className = 'status-msg error';
return;
}
var body = {
part_number: partNumber,
price: price,
stock: stock,
location: location || null
};
statusEl.textContent = 'Guardando...';
statusEl.className = 'status-msg';
api('/api/inventory/mapping', {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
}).then(function () {
statusEl.textContent = 'Mapeo guardado correctamente.';
statusEl.className = 'status-msg success';
toast('Mapeo guardado', 'success');
}).catch(function (err) {
statusEl.textContent = err.message || 'Error al guardar.';
statusEl.className = 'status-msg error';
});
});
// ================================================================
// TAB 2: Subir Inventario
// ================================================================
var dropZone = document.getElementById('drop-zone');
var fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', function () { fileInput.click(); });
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', function () {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
selectFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', function () {
if (fileInput.files.length) {
selectFile(fileInput.files[0]);
}
});
function selectFile(file) {
var validTypes = [
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
];
var ext = (file.name || '').split('.').pop().toLowerCase();
if (validTypes.indexOf(file.type) === -1 && ['csv', 'xls', 'xlsx'].indexOf(ext) === -1) {
toast('Formato no soportado. Usa CSV o Excel.', 'error');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast('El archivo excede 10MB.', 'error');
return;
}
selectedFile = file;
document.getElementById('selected-file-name').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
document.getElementById('selected-file').style.display = 'flex';
document.getElementById('btn-upload').disabled = false;
}
document.getElementById('btn-clear-file').addEventListener('click', function () {
selectedFile = null;
fileInput.value = '';
document.getElementById('selected-file').style.display = 'none';
document.getElementById('btn-upload').disabled = true;
});
document.getElementById('btn-upload').addEventListener('click', function () {
if (!selectedFile) return;
var btn = document.getElementById('btn-upload');
var statusEl = document.getElementById('upload-status');
btn.disabled = true;
statusEl.textContent = 'Subiendo...';
statusEl.className = 'status-msg';
var fd = new FormData();
fd.append('file', selectedFile);
fetch(API + '/api/inventory/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() },
body: fd
}).then(function (r) {
if (r.status === 401) {
return tryRefreshToken().then(function () {
return fetch(API + '/api/inventory/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + getToken() },
body: fd
});
});
}
return r;
}).then(function (r) {
return r.json().then(function (data) {
if (!r.ok) throw new Error(data.error || 'Error al subir');
return data;
});
}).then(function (data) {
statusEl.textContent = '';
showUploadResult(data);
toast('Archivo procesado correctamente.', 'success');
loadUploadHistory();
// Clear file selection
selectedFile = null;
fileInput.value = '';
document.getElementById('selected-file').style.display = 'none';
btn.disabled = true;
}).catch(function (err) {
statusEl.textContent = err.message || 'Error al subir archivo.';
statusEl.className = 'status-msg error';
btn.disabled = false;
});
});
function showUploadResult(data) {
var el = document.getElementById('upload-result');
var imported = data.imported || data.imported_count || 0;
var errors = data.errors || data.error_count || 0;
var samples = data.error_samples || [];
var html = '<h4>Resultado de la Carga</h4>';
html += '<div class="result-stats">';
html += '<span class="result-stat ok">Importados: ' + imported + '</span>';
html += '<span class="result-stat err">Errores: ' + errors + '</span>';
html += '</div>';
if (samples.length) {
html += '<div class="error-samples">';
html += '<strong>Ejemplos de errores:</strong>';
samples.forEach(function (s) {
html += '<p>' + esc(typeof s === 'string' ? s : JSON.stringify(s)) + '</p>';
});
html += '</div>';
}
el.innerHTML = html;
el.style.display = 'block';
}
function loadUploadHistory() {
api('/api/inventory/uploads').then(function (data) {
var rows = data.uploads || data.data || data || [];
var tbody = document.getElementById('history-body');
if (!Array.isArray(rows) || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="empty-row">Sin cargas previas</td></tr>';
return;
}
tbody.innerHTML = rows.map(function (r) {
return '<tr>'
+ '<td>' + esc(r.filename || r.archivo || '—') + '</td>'
+ '<td>' + statusBadge(r.status || r.estado) + '</td>'
+ '<td>' + (r.imported_count != null ? r.imported_count : (r.importados != null ? r.importados : '—')) + '</td>'
+ '<td>' + (r.error_count != null ? r.error_count : (r.errores != null ? r.errores : '—')) + '</td>'
+ '<td>' + fmtDate(r.created_at || r.fecha) + '</td>'
+ '</tr>';
}).join('');
}).catch(function () {
document.getElementById('history-body').innerHTML =
'<tr><td colspan="5" class="empty-row">Error al cargar historial</td></tr>';
});
}
// ================================================================
// TAB 3: Mi Inventario
// ================================================================
function loadInventory() {
var params = '?page=' + invPage;
if (invQuery) params += '&q=' + encodeURIComponent(invQuery);
api('/api/inventory/items' + params).then(function (data) {
var items = data.data || data.items || [];
var pagination = data.pagination || {};
var tbody = document.getElementById('inv-body');
if (!Array.isArray(items) || items.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-row">Sin articulos en inventario</td></tr>';
renderPagination(pagination);
return;
}
tbody.innerHTML = items.map(function (it) {
return '<tr>'
+ '<td><strong>' + esc(it.part_number) + '</strong></td>'
+ '<td>' + esc(it.name || it.nombre || '—') + '</td>'
+ '<td>' + fmtPrice(it.price || it.precio) + '</td>'
+ '<td>' + (it.stock != null ? it.stock : (it.existencias != null ? it.existencias : '—')) + '</td>'
+ '<td>' + esc(it.location || it.ubicacion || '—') + '</td>'
+ '<td>' + fmtDate(it.updated_at || it.actualizado) + '</td>'
+ '</tr>';
}).join('');
renderPagination(pagination);
}).catch(function () {
document.getElementById('inv-body').innerHTML =
'<tr><td colspan="6" class="empty-row">Error al cargar inventario</td></tr>';
});
}
function renderPagination(pg) {
var container = document.getElementById('inv-pagination');
if (!pg || !pg.total_pages || pg.total_pages <= 1) {
container.innerHTML = '';
return;
}
var current = pg.page || pg.current_page || 1;
var total = pg.total_pages;
var html = '';
html += '<button ' + (current <= 1 ? 'disabled' : '') + ' data-page="' + (current - 1) + '">Anterior</button>';
var start = Math.max(1, current - 2);
var end = Math.min(total, current + 2);
if (start > 1) {
html += '<button data-page="1">1</button>';
if (start > 2) html += '<button disabled>...</button>';
}
for (var i = start; i <= end; i++) {
html += '<button data-page="' + i + '"' + (i === current ? ' class="active"' : '') + '>' + i + '</button>';
}
if (end < total) {
if (end < total - 1) html += '<button disabled>...</button>';
html += '<button data-page="' + total + '">' + total + '</button>';
}
html += '<button ' + (current >= total ? 'disabled' : '') + ' data-page="' + (current + 1) + '">Siguiente</button>';
container.innerHTML = html;
container.querySelectorAll('button[data-page]').forEach(function (btn) {
btn.addEventListener('click', function () {
invPage = parseInt(btn.getAttribute('data-page'), 10);
loadInventory();
});
});
}
// Search
document.getElementById('btn-inv-search').addEventListener('click', function () {
invQuery = document.getElementById('inv-search').value.trim();
invPage = 1;
loadInventory();
});
document.getElementById('inv-search').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
invQuery = this.value.trim();
invPage = 1;
loadInventory();
}
});
// Clear All
document.getElementById('btn-clear-all').addEventListener('click', function () {
showConfirm(
'Limpiar Inventario',
'Se eliminaran todos los articulos de tu inventario. Esta accion no se puede deshacer.',
function () {
api('/api/inventory/items', {
method: 'DELETE',
headers: authHeaders()
}).then(function () {
toast('Inventario limpiado correctamente.', 'success');
loadInventory();
}).catch(function (err) {
toast(err.message || 'Error al limpiar inventario.', 'error');
});
}
);
});
// ================================================================
// Confirm Modal
// ================================================================
var confirmCallback = null;
function showConfirm(title, msg, onConfirm) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-msg').textContent = msg;
document.getElementById('confirm-modal').classList.add('active');
confirmCallback = onConfirm;
}
document.getElementById('confirm-cancel').addEventListener('click', function () {
document.getElementById('confirm-modal').classList.remove('active');
confirmCallback = null;
});
document.getElementById('confirm-ok').addEventListener('click', function () {
document.getElementById('confirm-modal').classList.remove('active');
if (confirmCallback) {
confirmCallback();
confirmCallback = null;
}
});
document.getElementById('confirm-modal').addEventListener('click', function (e) {
if (e.target === this) {
this.classList.remove('active');
confirmCallback = null;
}
});
// ================================================================
// Init
// ================================================================
if (checkAuth()) {
loadMapping();
}
})();