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:
485
dashboard/bodega.css
Normal file
485
dashboard/bodega.css
Normal 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
164
dashboard/bodega.html
Normal 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">📦</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">×</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
522
dashboard/bodega.js
Normal 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();
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user