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>
This commit is contained in:
2026-03-18 22:24:58 +00:00
parent 565f11aca6
commit 340d2fcef8
3 changed files with 1171 additions and 0 deletions

485
dashboard/bodega.css Normal file
View File

@@ -0,0 +1,485 @@
/* ============================================================
bodega.css -- Styles for Nexus Autoparts Warehouse (Bodega)
============================================================ */
/* --- Layout --- */
.bodega-container {
max-width: 1100px;
margin: 0 auto;
padding: 5.5rem 2rem 3rem;
}
/* --- Tabs --- */
.bodega-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.bodega-tab {
padding: 0.8rem 1.8rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.bodega-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.bodega-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.bodega-section {
display: none;
}
.bodega-section.active {
display: block;
}
/* --- Section Intro --- */
.section-intro {
margin-bottom: 1.5rem;
}
.section-intro h2 {
font-size: 1.3rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.section-intro p {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
/* --- Mapping Form --- */
.mapping-form {
max-width: 550px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.mapping-form .form-group {
margin-bottom: 1.25rem;
}
.mapping-form .form-label {
display: block;
margin-bottom: 0.4rem;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-primary);
}
.required {
color: var(--danger);
font-weight: 700;
}
.optional {
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 400;
}
.form-hint {
display: block;
margin-top: 0.3rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.form-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1.25rem;
}
.status-msg {
font-size: 0.85rem;
font-weight: 500;
}
.status-msg.success {
color: var(--success);
}
.status-msg.error {
color: var(--danger);
}
/* --- Upload Zone --- */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: var(--bg-card);
margin-bottom: 1rem;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--accent);
background: rgba(255, 107, 53, 0.05);
}
.upload-icon {
font-size: 3rem;
margin-bottom: 0.75rem;
}
.upload-text {
font-size: 1rem;
font-weight: 500;
margin-bottom: 0.3rem;
}
.upload-hint {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Selected File --- */
.selected-file {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.6rem 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.btn-icon {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
line-height: 1;
transition: color 0.2s;
}
.btn-icon:hover {
color: var(--danger);
}
/* --- Upload Result --- */
.upload-result {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.upload-result h4 {
margin-bottom: 0.75rem;
font-size: 1rem;
}
.result-stats {
display: flex;
gap: 1.5rem;
margin-bottom: 0.75rem;
}
.result-stat {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.result-stat.ok {
color: var(--success);
}
.result-stat.err {
color: var(--danger);
}
.error-samples {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 0.8rem;
color: var(--text-secondary);
max-height: 150px;
overflow-y: auto;
}
.error-samples p {
margin-bottom: 0.3rem;
}
/* --- History --- */
.history-section {
margin-top: 2rem;
}
.history-section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* --- Tables --- */
.table-wrap {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.data-table thead th {
background: var(--bg-secondary);
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table tbody td {
padding: 0.7rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
.data-table tbody tr:hover {
background: var(--bg-hover);
}
.empty-row {
text-align: center;
color: var(--text-secondary);
padding: 2rem 1rem !important;
}
/* --- Status Badges --- */
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 10px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-success {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.badge-error {
background: rgba(255, 68, 68, 0.15);
color: var(--danger);
}
.badge-pending {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.badge-processing {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
/* --- Inventory Toolbar --- */
.inventory-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-box {
display: flex;
gap: 0.5rem;
flex: 1;
max-width: 500px;
}
.search-box .form-input {
flex: 1;
}
.btn-danger {
background: rgba(255, 68, 68, 0.15);
border: 1px solid var(--danger);
color: var(--danger);
padding: 0.7rem 1.5rem;
border-radius: 10px;
font-weight: 600;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s;
}
.btn-danger:hover {
background: var(--danger);
color: white;
}
/* --- Pagination --- */
.bodega-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
flex-wrap: wrap;
}
.bodega-pagination button {
padding: 0.4rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.bodega-pagination button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.bodega-pagination button.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.bodega-pagination button:disabled {
opacity: 0.4;
cursor: default;
}
/* --- Confirm Modal --- */
.confirm-box {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 2rem;
max-width: 420px;
width: 100%;
}
.confirm-box h3 {
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.confirm-box p {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* --- Toast --- */
#toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.8rem 1.2rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
animation: fadeIn 0.3s ease;
max-width: 350px;
}
.toast.success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--success);
color: var(--success);
}
.toast.error {
background: rgba(255, 68, 68, 0.15);
border: 1px solid var(--danger);
color: var(--danger);
}
/* --- Responsive --- */
@media (max-width: 768px) {
.bodega-container {
padding: 5rem 1rem 2rem;
}
.bodega-tabs {
overflow-x: auto;
}
.bodega-tab {
padding: 0.7rem 1.2rem;
font-size: 0.85rem;
white-space: nowrap;
}
.inventory-toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box {
max-width: none;
}
.result-stats {
flex-direction: column;
gap: 0.5rem;
}
}

164
dashboard/bodega.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bodega — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/bodega.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="bodega-container">
<!-- Main Tabs -->
<div class="bodega-tabs">
<button class="bodega-tab active" data-tab="mapeo">Mapeo de Columnas</button>
<button class="bodega-tab" data-tab="subir">Subir Inventario</button>
<button class="bodega-tab" data-tab="inventario">Mi Inventario</button>
</div>
<!-- ============================================ -->
<!-- TAB 1: Mapeo de Columnas -->
<!-- ============================================ -->
<div id="section-mapeo" class="bodega-section active">
<div class="section-intro">
<h2>Mapeo de Columnas</h2>
<p>Configura como se mapean las columnas de tu archivo CSV/Excel a los campos del sistema. Escribe el nombre exacto de la columna en tu archivo.</p>
</div>
<div class="mapping-form">
<div class="form-group">
<label class="form-label">Numero de Parte <span class="required">*</span></label>
<input id="map-part-number" type="text" class="form-input" placeholder="Ej: PartNo, SKU, Numero...">
<span class="form-hint">Campo del sistema: part_number</span>
</div>
<div class="form-group">
<label class="form-label">Precio <span class="required">*</span></label>
<input id="map-price" type="text" class="form-input" placeholder="Ej: Precio, Price, Costo...">
<span class="form-hint">Campo del sistema: price</span>
</div>
<div class="form-group">
<label class="form-label">Existencias <span class="required">*</span></label>
<input id="map-stock" type="text" class="form-input" placeholder="Ej: Stock, Qty, Existencia...">
<span class="form-hint">Campo del sistema: stock</span>
</div>
<div class="form-group">
<label class="form-label">Ubicacion / Sucursal <span class="optional">(opcional)</span></label>
<input id="map-location" type="text" class="form-input" placeholder="Ej: Sucursal, Bodega, Location...">
<span class="form-hint">Campo del sistema: location</span>
</div>
<div class="form-actions">
<button id="btn-save-mapping" class="btn btn-primary">Guardar Mapeo</button>
<span id="mapping-status" class="status-msg"></span>
</div>
</div>
</div>
<!-- ============================================ -->
<!-- TAB 2: Subir Inventario -->
<!-- ============================================ -->
<div id="section-subir" class="bodega-section">
<div class="section-intro">
<h2>Subir Inventario</h2>
<p>Sube un archivo CSV o Excel con tu inventario. Asegurate de haber configurado el mapeo de columnas primero.</p>
</div>
<div class="upload-zone" id="drop-zone">
<div class="upload-icon">&#128230;</div>
<p class="upload-text">Arrastra tu archivo aqui o haz clic para seleccionar</p>
<p class="upload-hint">CSV, XLS, XLSX — Max 10MB</p>
<input type="file" id="file-input" accept=".csv,.xls,.xlsx" style="display:none;">
</div>
<div id="selected-file" class="selected-file" style="display:none;">
<span id="selected-file-name"></span>
<button id="btn-clear-file" class="btn-icon" title="Quitar archivo">&times;</button>
</div>
<div class="form-actions">
<button id="btn-upload" class="btn btn-primary" disabled>Subir Archivo</button>
<span id="upload-status" class="status-msg"></span>
</div>
<div id="upload-result" class="upload-result" style="display:none;"></div>
<div class="history-section">
<h3>Historial de Cargas</h3>
<div id="upload-history" class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Archivo</th>
<th>Estado</th>
<th>Importados</th>
<th>Errores</th>
<th>Fecha</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td colspan="5" class="empty-row">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ============================================ -->
<!-- TAB 3: Mi Inventario -->
<!-- ============================================ -->
<div id="section-inventario" class="bodega-section">
<div class="section-intro">
<h2>Mi Inventario</h2>
</div>
<div class="inventory-toolbar">
<div class="search-box">
<input id="inv-search" type="text" class="form-input" placeholder="Buscar por numero de parte o nombre...">
<button id="btn-inv-search" class="btn btn-primary">Buscar</button>
</div>
<button id="btn-clear-all" class="btn btn-danger">Limpiar Todo</button>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Numero de Parte</th>
<th>Nombre</th>
<th>Precio</th>
<th>Existencias</th>
<th>Ubicacion</th>
<th>Actualizado</th>
</tr>
</thead>
<tbody id="inv-body">
<tr><td colspan="6" class="empty-row">Cargando...</td></tr>
</tbody>
</table>
</div>
<div id="inv-pagination" class="bodega-pagination"></div>
</div>
</div>
<!-- Confirm Modal -->
<div id="confirm-modal" class="modal-overlay">
<div class="confirm-box">
<h3 id="confirm-title">Confirmar</h3>
<p id="confirm-msg"></p>
<div class="confirm-actions">
<button id="confirm-cancel" class="btn btn-secondary">Cancelar</button>
<button id="confirm-ok" class="btn btn-danger">Confirmar</button>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toast-container"></div>
<script src="/nav.js"></script>
<script src="/bodega.js"></script>
</body>
</html>

522
dashboard/bodega.js Normal file
View File

@@ -0,0 +1,522 @@
/**
* 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();
}
})();