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>
523 lines
20 KiB
JavaScript
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();
|
|
}
|
|
|
|
})();
|