Major features: - Pixel-Perfect glassmorphism design (landing + POS + public catalog) - OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types) - Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications) - Peer-to-peer inventory (multi-instance, LAN discovery) - WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations - Smart unified search (VIN/plate/part_number/keyword auto-detect) - Shop Supplies tab (vehicle-independent parts) - Chatbot AI fallback chain (5 models) + response cache - CSV inventory import tool + setup_instance.sh installer - Tablet-responsive CSS + sidebar toggle - Filters, export CSV, employee edit, business data save - Quotation system (WA→POS) with auto-print on confirmation - Live stats on landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
738 lines
32 KiB
HTML
738 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="es" data-theme="industrial">
|
||
<head>
|
||
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Marketplace B2B — Nexus Autoparts POS</title>
|
||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||
<meta name="theme-color" content="#F5A623" />
|
||
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
font-family: var(--font-body); font-size: var(--text-body-sm);
|
||
background: var(--color-bg-base); color: var(--color-text-primary);
|
||
min-height: 100vh; display: flex; flex-direction: column;
|
||
}
|
||
|
||
/* ── Page header ── */
|
||
.page-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: var(--space-4) var(--space-6);
|
||
background: var(--glass-bg-strong); backdrop-filter: blur(16px);
|
||
border-bottom: 1px solid var(--glass-border);
|
||
}
|
||
.page-title {
|
||
font-family: var(--font-heading); font-size: var(--text-h4);
|
||
font-weight: var(--heading-weight-primary); color: var(--color-text-accent);
|
||
text-transform: uppercase; letter-spacing: var(--tracking-wide);
|
||
}
|
||
.page-title::before { content: '// '; opacity: 0.5; font-family: var(--font-mono); }
|
||
.user-role {
|
||
font-family: var(--font-mono); font-size: var(--text-caption);
|
||
color: var(--color-text-muted); text-transform: uppercase;
|
||
letter-spacing: var(--tracking-widest);
|
||
padding: var(--space-1) var(--space-3);
|
||
border: 1px dashed var(--glass-border); border-radius: var(--radius-sm);
|
||
}
|
||
|
||
/* ── Tab bar ── */
|
||
.tab-bar {
|
||
display: flex; gap: var(--space-1);
|
||
padding: 0 var(--space-6); background: var(--color-bg-base);
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
.tab-btn {
|
||
background: transparent; border: none;
|
||
padding: var(--space-3) var(--space-5);
|
||
color: var(--color-text-muted); cursor: pointer;
|
||
font-family: var(--font-body); font-size: var(--text-body-sm);
|
||
font-weight: var(--font-weight-semibold);
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.tab-btn:hover { color: var(--color-text-accent); }
|
||
.tab-btn.active {
|
||
color: var(--color-text-accent);
|
||
border-bottom-color: var(--color-primary);
|
||
}
|
||
.tab-panel { display: none; padding: var(--space-6); flex: 1; overflow-y: auto; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
/* ── Search bar ── */
|
||
.search-row {
|
||
display: flex; gap: var(--space-3); margin-bottom: var(--space-5);
|
||
flex-wrap: wrap;
|
||
}
|
||
.search-row input {
|
||
flex: 1; min-width: 240px;
|
||
padding: var(--space-3) var(--space-4);
|
||
background: var(--glass-bg);
|
||
border: 1px solid var(--glass-border);
|
||
color: var(--color-text-primary);
|
||
font-family: var(--font-body); font-size: var(--text-body);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.search-row button {
|
||
padding: var(--space-3) var(--space-6);
|
||
background: var(--gradient-accent); color: var(--btn-primary-text);
|
||
border: none; border-radius: var(--radius-md);
|
||
font-weight: var(--font-weight-semibold); cursor: pointer;
|
||
}
|
||
|
||
/* ── Results table/grid ── */
|
||
.results-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||
gap: var(--space-4);
|
||
}
|
||
.part-card {
|
||
background: var(--glass-bg); backdrop-filter: blur(12px);
|
||
border: 1px solid var(--glass-border); border-radius: var(--radius-lg);
|
||
padding: var(--space-4); cursor: pointer;
|
||
transition: all 0.25s ease;
|
||
}
|
||
.part-card:hover {
|
||
border-color: var(--color-border-accent);
|
||
box-shadow: 0 4px 20px var(--glow-color-soft);
|
||
transform: translateY(-2px);
|
||
}
|
||
.part-card__oem {
|
||
font-family: var(--font-mono); font-size: var(--text-caption);
|
||
color: var(--color-text-accent); font-weight: var(--font-weight-bold);
|
||
}
|
||
.part-card__name {
|
||
font-size: var(--text-body); font-weight: var(--font-weight-semibold);
|
||
margin-top: var(--space-1);
|
||
}
|
||
.part-card__meta {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
margin-top: var(--space-3); padding-top: var(--space-3);
|
||
border-top: 1px dashed var(--glass-border);
|
||
font-size: var(--text-caption);
|
||
}
|
||
.price-range {
|
||
font-family: var(--font-mono); color: var(--color-text-accent);
|
||
font-weight: var(--font-weight-bold);
|
||
}
|
||
.bodega-count {
|
||
color: var(--color-text-muted);
|
||
}
|
||
.stock-pill {
|
||
display: inline-block; padding: 2px 8px;
|
||
background: rgba(63,185,80,0.15); color: #3FB950;
|
||
border: 1px solid rgba(63,185,80,0.3);
|
||
border-radius: var(--radius-full);
|
||
font-size: 11px; font-weight: var(--font-weight-bold);
|
||
}
|
||
|
||
/* ── PO list table ── */
|
||
.po-table {
|
||
width: 100%; border-collapse: collapse;
|
||
background: var(--glass-bg); backdrop-filter: blur(12px);
|
||
border: 1px solid var(--glass-border); border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
}
|
||
.po-table th, .po-table td {
|
||
padding: var(--space-3) var(--space-4);
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--glass-border);
|
||
}
|
||
.po-table th {
|
||
background: var(--glass-bg-strong);
|
||
font-family: var(--font-mono); font-size: var(--text-caption);
|
||
text-transform: uppercase; letter-spacing: var(--tracking-widest);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.po-table tbody tr { cursor: pointer; transition: background 0.15s; }
|
||
.po-table tbody tr:hover { background: var(--glass-highlight); }
|
||
|
||
/* ── Status badges ── */
|
||
.status-badge {
|
||
display: inline-block; padding: 2px 10px; border-radius: var(--radius-full);
|
||
font-size: 11px; font-weight: var(--font-weight-bold);
|
||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||
}
|
||
.status-draft { background: rgba(130,130,130,0.2); color: #888; }
|
||
.status-submitted { background: rgba(245,166,35,0.15); color: #F5A623; border: 1px solid rgba(245,166,35,0.3); }
|
||
.status-confirmed { background: rgba(0,212,255,0.15); color: #00D4FF; border: 1px solid rgba(0,212,255,0.3); }
|
||
.status-ready { background: rgba(167,139,250,0.15); color: #A78BFA; border: 1px solid rgba(167,139,250,0.3); }
|
||
.status-delivered { background: rgba(63,185,80,0.15); color: #3FB950; border: 1px solid rgba(63,185,80,0.3); }
|
||
.status-closed { background: rgba(255,255,255,0.05); color: #888; }
|
||
.status-rejected { background: rgba(248,81,73,0.15); color: #F85149; border: 1px solid rgba(248,81,73,0.3); }
|
||
|
||
/* ── Modals ── */
|
||
.modal-overlay {
|
||
display: none; position: fixed; inset: 0; z-index: 1000;
|
||
background: var(--overlay-backdrop); backdrop-filter: blur(4px);
|
||
align-items: flex-start; justify-content: center;
|
||
padding: var(--space-8) var(--space-4); overflow-y: auto;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal-content {
|
||
background: var(--glass-bg-strong); backdrop-filter: blur(24px);
|
||
border: 1px solid var(--glass-border); border-radius: var(--radius-lg);
|
||
max-width: 700px; width: 100%;
|
||
padding: var(--space-6); position: relative;
|
||
}
|
||
.modal-close {
|
||
position: absolute; top: var(--space-3); right: var(--space-3);
|
||
background: none; border: none; color: var(--color-text-muted);
|
||
font-size: 1.4rem; cursor: pointer;
|
||
}
|
||
.modal-title {
|
||
font-family: var(--font-heading); font-size: var(--text-h5);
|
||
margin-bottom: var(--space-4);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center; padding: var(--space-12);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.empty-state h3 { margin-bottom: var(--space-2); font-family: var(--font-heading); }
|
||
|
||
/* ── CSV upload box ── */
|
||
.upload-box {
|
||
background: var(--glass-bg); border: 1px dashed var(--glass-border);
|
||
border-radius: var(--radius-lg); padding: var(--space-5);
|
||
margin-bottom: var(--space-5);
|
||
}
|
||
.upload-box label {
|
||
display: block; margin-bottom: var(--space-2); font-weight: var(--font-weight-semibold);
|
||
}
|
||
.upload-box textarea {
|
||
width: 100%; height: 160px;
|
||
padding: var(--space-3); font-family: var(--font-mono); font-size: 12px;
|
||
background: var(--glass-bg); border: 1px solid var(--glass-border);
|
||
color: var(--color-text-primary); border-radius: var(--radius-md);
|
||
resize: vertical;
|
||
}
|
||
.upload-box .hint {
|
||
font-size: var(--text-caption); color: var(--color-text-muted);
|
||
margin-top: var(--space-1);
|
||
}
|
||
|
||
/* Item list inside the create-PO modal */
|
||
.cart-items {
|
||
border: 1px solid var(--glass-border); border-radius: var(--radius-md);
|
||
margin: var(--space-3) 0;
|
||
max-height: 260px; overflow-y: auto;
|
||
}
|
||
.cart-row {
|
||
display: flex; gap: var(--space-3); align-items: center;
|
||
padding: var(--space-2) var(--space-3);
|
||
border-bottom: 1px solid var(--glass-border);
|
||
}
|
||
.cart-row:last-child { border-bottom: none; }
|
||
.cart-row input[type="number"] { width: 70px; }
|
||
.cart-row .remove-btn {
|
||
background: none; border: none; color: #F85149; cursor: pointer;
|
||
font-size: 1.2rem;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="page-header">
|
||
<div class="page-title">Marketplace B2B</div>
|
||
<div style="display:flex;gap:var(--space-3);align-items:center;">
|
||
<span class="user-role" id="userRoleTag">cargando...</span>
|
||
<a href="/pos/catalog" style="color:var(--color-text-secondary);">← POS</a>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Tab bar — rendered dynamically based on role -->
|
||
<nav class="tab-bar" id="tabBar">
|
||
<!-- buttons injected by JS -->
|
||
</nav>
|
||
|
||
<!-- ══════════ TAB: Browse (buyer) ══════════ -->
|
||
<section class="tab-panel" id="panel-browse">
|
||
<div class="search-row">
|
||
<input type="text" id="browseQuery" placeholder="Buscar parte, marca, numero OEM..." autocomplete="off">
|
||
<input type="text" id="browseCity" placeholder="Ciudad (opcional)" style="max-width:200px;">
|
||
<button onclick="runBrowseSearch()">Buscar</button>
|
||
</div>
|
||
<div id="browseResults" class="results-grid"></div>
|
||
</section>
|
||
|
||
<!-- ══════════ TAB: Mis POs (buyer) ══════════ -->
|
||
<section class="tab-panel" id="panel-mine">
|
||
<h2 style="margin-bottom:var(--space-4);font-family:var(--font-heading);">Mis Pedidos</h2>
|
||
<div id="minePOList"></div>
|
||
</section>
|
||
|
||
<!-- ══════════ TAB: Inbox (seller) ══════════ -->
|
||
<section class="tab-panel" id="panel-inbox">
|
||
<h2 style="margin-bottom:var(--space-4);font-family:var(--font-heading);">Pedidos Recibidos</h2>
|
||
<div id="inboxPOList"></div>
|
||
</section>
|
||
|
||
<!-- ══════════ TAB: Mi Inventario (seller) ══════════ -->
|
||
<section class="tab-panel" id="panel-inventory">
|
||
<h2 style="margin-bottom:var(--space-4);font-family:var(--font-heading);">Mi Inventario</h2>
|
||
<div class="upload-box">
|
||
<label>Cargar inventario via CSV</label>
|
||
<textarea id="csvText" placeholder="part_number,stock,price AB-123,5,150.50 CD-456,12,89.00"></textarea>
|
||
<div class="hint">
|
||
Columnas requeridas: <code>part_number, stock, price</code>.
|
||
Opcionales: <code>min_order, warehouse_location, currency</code>.
|
||
</div>
|
||
<button style="margin-top:var(--space-3);padding:var(--space-3) var(--space-6);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-md);font-weight:bold;cursor:pointer;" onclick="uploadCSV()">Subir CSV</button>
|
||
<div id="uploadResult" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ══════════ PO Detail Modal ══════════ -->
|
||
<div class="modal-overlay" id="poModal">
|
||
<div class="modal-content">
|
||
<button class="modal-close" onclick="closeModal('poModal')">✕</button>
|
||
<div class="modal-title" id="poModalTitle">Pedido</div>
|
||
<div id="poModalBody">cargando...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════ Part Detail / Create PO Modal ══════════ -->
|
||
<div class="modal-overlay" id="partModal">
|
||
<div class="modal-content">
|
||
<button class="modal-close" onclick="closeModal('partModal')">✕</button>
|
||
<div class="modal-title" id="partModalTitle">Detalle de parte</div>
|
||
<div id="partModalBody">cargando...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
var API = '/pos/api/marketplace';
|
||
var TOKEN = localStorage.getItem('pos_token') || '';
|
||
|
||
// ── Auth headers helper ──
|
||
function headers() {
|
||
return {
|
||
'Authorization': 'Bearer ' + TOKEN,
|
||
'Content-Type': 'application/json',
|
||
'X-Device-Id': localStorage.getItem('pos_device_id') || 'web',
|
||
};
|
||
}
|
||
|
||
function apiFetch(path, opts) {
|
||
opts = opts || {};
|
||
opts.headers = headers();
|
||
return fetch(API + path, opts).then(function (r) {
|
||
if (r.status === 401) {
|
||
window.location.href = '/pos/login';
|
||
return null;
|
||
}
|
||
return r.json();
|
||
});
|
||
}
|
||
|
||
// ── HTML escape ──
|
||
function esc(s) {
|
||
var d = document.createElement('div');
|
||
d.textContent = s || '';
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function fmt(n) {
|
||
return (n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
// ── Role detection (reads from JWT payload) ──
|
||
// Since we don't expose a /whoami endpoint, try to derive role from
|
||
// the orders/inbox endpoint — if it returns 403, the user is a buyer.
|
||
// Admin users can see everything so default to 'admin' view.
|
||
var userRole = 'buyer'; // default
|
||
function detectRole() {
|
||
// Use the /whoami endpoint to reliably detect the marketplace role.
|
||
return apiFetch('/whoami').then(function (data) {
|
||
if (data && data.marketplace_role) {
|
||
userRole = data.marketplace_role;
|
||
} else {
|
||
userRole = 'buyer';
|
||
}
|
||
document.getElementById('userRoleTag').textContent =
|
||
userRole + (data && data.bodega_id ? ' · bodega #' + data.bodega_id : '');
|
||
renderTabs();
|
||
});
|
||
}
|
||
|
||
// ── Render tab bar based on role ──
|
||
function renderTabs() {
|
||
var tabs;
|
||
if (userRole === 'seller' || userRole === 'admin') {
|
||
tabs = [
|
||
{ id: 'inbox', label: 'Pedidos Recibidos' },
|
||
{ id: 'inventory', label: 'Mi Inventario' },
|
||
{ id: 'browse', label: 'Explorar' },
|
||
];
|
||
} else {
|
||
tabs = [
|
||
{ id: 'browse', label: 'Explorar' },
|
||
{ id: 'mine', label: 'Mis Pedidos' },
|
||
];
|
||
}
|
||
|
||
var bar = document.getElementById('tabBar');
|
||
bar.innerHTML = tabs.map(function (t) {
|
||
return '<button class="tab-btn" data-tab="' + t.id + '" onclick="switchTab(\'' + t.id + '\')">' + t.label + '</button>';
|
||
}).join('');
|
||
|
||
// Open the first tab by default
|
||
switchTab(tabs[0].id);
|
||
}
|
||
|
||
window.switchTab = function (id) {
|
||
document.querySelectorAll('.tab-btn').forEach(function (b) {
|
||
b.classList.toggle('active', b.dataset.tab === id);
|
||
});
|
||
document.querySelectorAll('.tab-panel').forEach(function (p) {
|
||
p.classList.toggle('active', p.id === 'panel-' + id);
|
||
});
|
||
// Lazy-load content per tab
|
||
if (id === 'browse') runBrowseSearch();
|
||
if (id === 'mine') loadMyPOs();
|
||
if (id === 'inbox') loadInbox();
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// BROWSE
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
window.runBrowseSearch = function () {
|
||
var q = document.getElementById('browseQuery').value.trim();
|
||
var city = document.getElementById('browseCity').value.trim();
|
||
var params = [];
|
||
if (q) params.push('q=' + encodeURIComponent(q));
|
||
if (city) params.push('city=' + encodeURIComponent(city));
|
||
var url = '/inventory/search' + (params.length ? '?' + params.join('&') : '');
|
||
|
||
var grid = document.getElementById('browseResults');
|
||
grid.innerHTML = '<div class="empty-state">Buscando...</div>';
|
||
|
||
apiFetch(url).then(function (resp) {
|
||
if (!resp || !resp.data) { grid.innerHTML = '<div class="empty-state"><h3>Error</h3></div>'; return; }
|
||
if (resp.data.length === 0) {
|
||
grid.innerHTML = '<div class="empty-state"><h3>Sin resultados</h3><p>Ninguna bodega tiene esta parte en stock.</p></div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = resp.data.map(function (p) {
|
||
var priceStr = p.min_price === p.max_price
|
||
? '$' + fmt(p.min_price)
|
||
: '$' + fmt(p.min_price) + ' – $' + fmt(p.max_price);
|
||
return '<div class="part-card" onclick="openPartDetail(' + p.id_part + ')">' +
|
||
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
|
||
'<div class="part-card__name">' + esc(p.name) + '</div>' +
|
||
'<div class="part-card__meta">' +
|
||
'<span class="price-range">' + priceStr + '</span>' +
|
||
'<span class="stock-pill">' + esc(p.total_stock_hint) + '</span>' +
|
||
'</div>' +
|
||
'<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-top:var(--space-2);">' +
|
||
p.bodega_count + ' bodega' + (p.bodega_count !== 1 ? 's' : '') +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
});
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// PART DETAIL + CREATE PO
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
var cart = []; // {part_id, bodega_id, part_name, oem, quantity, unit_price}
|
||
|
||
window.openPartDetail = function (partId) {
|
||
var modal = document.getElementById('partModal');
|
||
modal.classList.add('open');
|
||
document.getElementById('partModalBody').innerHTML = 'Cargando bodegas...';
|
||
|
||
apiFetch('/inventory/part/' + partId).then(function (resp) {
|
||
if (!resp || !resp.data) return;
|
||
var bodegas = resp.data;
|
||
if (bodegas.length === 0) {
|
||
document.getElementById('partModalBody').innerHTML = '<div class="empty-state">Ninguna bodega tiene esta parte en stock.</div>';
|
||
return;
|
||
}
|
||
var html = '<p style="color:var(--color-text-muted);margin-bottom:var(--space-3);">Elige una bodega para ordenar:</p>';
|
||
html += '<div style="display:flex;flex-direction:column;gap:var(--space-2);">';
|
||
bodegas.forEach(function (b) {
|
||
html += '<div style="padding:var(--space-3);border:1px solid var(--glass-border);border-radius:var(--radius-md);display:flex;justify-content:space-between;align-items:center;">' +
|
||
'<div>' +
|
||
'<strong>' + esc(b.name) + '</strong>' +
|
||
'<div style="color:var(--color-text-muted);font-size:var(--text-caption);">' + esc(b.city || '') + ' · ' + esc(b.stock_hint) + '</div>' +
|
||
'</div>' +
|
||
'<div style="text-align:right;">' +
|
||
'<div class="price-range">$' + fmt(b.price) + '</div>' +
|
||
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
});
|
||
html += '</div>';
|
||
document.getElementById('partModalBody').innerHTML = html;
|
||
});
|
||
};
|
||
|
||
window.createOrderFor = function (partId, bodegaId, bodegaName, price) {
|
||
var qty = prompt('Cantidad a ordenar para "' + bodegaName + '":', '1');
|
||
if (!qty) return;
|
||
qty = parseInt(qty);
|
||
if (qty < 1) return;
|
||
|
||
var notes = prompt('Notas para la bodega (opcional):', '') || '';
|
||
|
||
// Create PO draft
|
||
apiFetch('/orders', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
bodega_id: bodegaId,
|
||
items: [{ part_id: partId, quantity: qty, unit_price: price }],
|
||
delivery_method: 'pickup',
|
||
buyer_notes: notes,
|
||
}),
|
||
}).then(function (resp) {
|
||
if (!resp || resp.error) {
|
||
alert('Error creando pedido: ' + (resp && resp.error || 'desconocido'));
|
||
return;
|
||
}
|
||
// Auto-submit immediately for now
|
||
apiFetch('/orders/' + resp.po_id + '/transition', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ new_status: 'submitted' }),
|
||
}).then(function (tr) {
|
||
if (tr && tr.ok) {
|
||
alert('✓ Pedido #' + resp.po_id + ' enviado a ' + bodegaName);
|
||
closeModal('partModal');
|
||
if (userRole === 'buyer') switchTab('mine');
|
||
} else {
|
||
alert('Pedido creado #' + resp.po_id + ' pero no se pudo enviar: ' + (tr && tr.error || ''));
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// MIS POS (buyer)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
window.loadMyPOs = function () {
|
||
var container = document.getElementById('minePOList');
|
||
container.innerHTML = 'Cargando...';
|
||
apiFetch('/orders/mine?only_mine=false').then(function (resp) {
|
||
renderPOList(container, resp, 'Aun no has creado pedidos.');
|
||
});
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// INBOX (seller)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
window.loadInbox = function () {
|
||
var container = document.getElementById('inboxPOList');
|
||
container.innerHTML = 'Cargando...';
|
||
apiFetch('/orders/inbox').then(function (resp) {
|
||
renderPOList(container, resp, 'Aun no hay pedidos recibidos.');
|
||
});
|
||
};
|
||
|
||
function renderPOList(container, resp, emptyMsg) {
|
||
if (!resp || resp.error || !resp.data) {
|
||
container.innerHTML = '<div class="empty-state"><h3>Error</h3><p>' + esc(resp && resp.error || 'unknown') + '</p></div>';
|
||
return;
|
||
}
|
||
if (resp.data.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><h3>Sin pedidos</h3><p>' + emptyMsg + '</p></div>';
|
||
return;
|
||
}
|
||
var html = '<table class="po-table"><thead><tr>' +
|
||
'<th>#</th>' +
|
||
'<th>Estado</th>' +
|
||
(userRole === 'seller' ? '<th>Comprador</th>' : '<th>Bodega</th>') +
|
||
'<th>Items</th>' +
|
||
'<th>Total</th>' +
|
||
'<th>Creado</th>' +
|
||
'</tr></thead><tbody>';
|
||
resp.data.forEach(function (po) {
|
||
var when = po.submitted_at ? new Date(po.submitted_at).toLocaleDateString('es-MX') : '—';
|
||
html += '<tr onclick="openPODetail(' + po.id_po + ')">' +
|
||
'<td><strong>#' + po.id_po + '</strong></td>' +
|
||
'<td><span class="status-badge status-' + po.status + '">' + po.status + '</span></td>' +
|
||
'<td>' + esc(po.bodega_name || po.buyer_display_name || '—') + '</td>' +
|
||
'<td>' + po.item_count + '</td>' +
|
||
'<td>$' + fmt(po.total_amount) + '</td>' +
|
||
'<td>' + when + '</td>' +
|
||
'</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// PO DETAIL MODAL
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
window.openPODetail = function (poId) {
|
||
var modal = document.getElementById('poModal');
|
||
modal.classList.add('open');
|
||
document.getElementById('poModalTitle').textContent = 'Pedido #' + poId;
|
||
document.getElementById('poModalBody').innerHTML = 'Cargando...';
|
||
|
||
apiFetch('/orders/' + poId).then(function (po) {
|
||
if (!po || po.error) {
|
||
document.getElementById('poModalBody').innerHTML = '<div>Error: ' + esc(po && po.error || '') + '</div>';
|
||
return;
|
||
}
|
||
var html = '<div style="display:flex;justify-content:space-between;margin-bottom:var(--space-3);">' +
|
||
'<div>' +
|
||
'<div style="font-size:var(--text-caption);color:var(--color-text-muted);">COMPRADOR</div>' +
|
||
'<div>' + esc(po.buyer_display_name) + '</div>' +
|
||
'</div>' +
|
||
'<div>' +
|
||
'<div style="font-size:var(--text-caption);color:var(--color-text-muted);">BODEGA</div>' +
|
||
'<div>' + esc(po.bodega_name) + '</div>' +
|
||
'</div>' +
|
||
'<div>' +
|
||
'<div style="font-size:var(--text-caption);color:var(--color-text-muted);">ESTADO</div>' +
|
||
'<span class="status-badge status-' + po.status + '">' + po.status + '</span>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
html += '<h4 style="margin:var(--space-4) 0 var(--space-2);">Items</h4>';
|
||
html += '<table class="po-table"><thead><tr><th>OEM</th><th>Nombre</th><th>Cant</th><th>Precio</th><th>Subtotal</th></tr></thead><tbody>';
|
||
po.items.forEach(function (it) {
|
||
html += '<tr>' +
|
||
'<td style="font-family:var(--font-mono);">' + esc(it.oem_part_number) + '</td>' +
|
||
'<td>' + esc(it.part_name) + '</td>' +
|
||
'<td>' + it.quantity + '</td>' +
|
||
'<td>$' + fmt(it.unit_price) + '</td>' +
|
||
'<td>$' + fmt(it.subtotal) + '</td>' +
|
||
'</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
|
||
html += '<div style="text-align:right;font-size:var(--text-h5);margin-top:var(--space-3);"><strong>Total: $' + fmt(po.total_amount) + ' ' + po.currency + '</strong></div>';
|
||
|
||
// Transition buttons based on current status + role
|
||
var actions = getActionsForStatus(po.status, userRole);
|
||
if (actions.length) {
|
||
html += '<div style="margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--glass-border);display:flex;gap:var(--space-2);">';
|
||
actions.forEach(function (a) {
|
||
html += '<button onclick="transitionPO(' + po.id_po + ', \'' + a.status + '\')" ' +
|
||
'style="padding:var(--space-2) var(--space-4);background:' + a.color + ';color:#fff;border:none;border-radius:var(--radius-md);font-weight:bold;cursor:pointer;">' +
|
||
a.label + '</button>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// Status history
|
||
if (po.history && po.history.length) {
|
||
html += '<h4 style="margin:var(--space-4) 0 var(--space-2);">Historial</h4>';
|
||
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);">';
|
||
po.history.forEach(function (h) {
|
||
html += '<div>' + new Date(h.at).toLocaleString('es-MX') + ' — ' +
|
||
(h.from_status ? h.from_status + ' → ' : '') + h.to_status +
|
||
' (' + h.actor_kind + ')' +
|
||
(h.note ? ' · ' + esc(h.note) : '') +
|
||
'</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
document.getElementById('poModalBody').innerHTML = html;
|
||
});
|
||
};
|
||
|
||
function getActionsForStatus(status, role) {
|
||
// Map: {role: {status: [{status, label, color}]}}
|
||
var actionMap = {
|
||
buyer: {
|
||
ready: [{ status: 'delivered', label: 'Marcar recibido', color: '#3FB950' }],
|
||
delivered: [{ status: 'closed', label: 'Cerrar pedido', color: '#888' }],
|
||
},
|
||
seller: {
|
||
submitted: [
|
||
{ status: 'confirmed', label: 'Confirmar', color: '#00D4FF' },
|
||
{ status: 'rejected', label: 'Rechazar', color: '#F85149' },
|
||
],
|
||
confirmed: [{ status: 'ready', label: 'Marcar listo', color: '#A78BFA' }],
|
||
ready: [{ status: 'delivered', label: 'Marcar entregado', color: '#3FB950' }],
|
||
},
|
||
};
|
||
return (actionMap[role] || {})[status] || [];
|
||
}
|
||
|
||
window.transitionPO = function (poId, newStatus) {
|
||
var note = prompt('Nota opcional:', '') || '';
|
||
apiFetch('/orders/' + poId + '/transition', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ new_status: newStatus, note: note }),
|
||
}).then(function (r) {
|
||
if (r && r.ok) {
|
||
openPODetail(poId);
|
||
} else {
|
||
alert('Error: ' + (r && r.error || 'unknown'));
|
||
}
|
||
});
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// CSV UPLOAD (seller)
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
window.uploadCSV = function () {
|
||
var csv = document.getElementById('csvText').value;
|
||
if (!csv.trim()) { alert('Pega un CSV primero'); return; }
|
||
|
||
var resultDiv = document.getElementById('uploadResult');
|
||
resultDiv.innerHTML = 'Subiendo...';
|
||
|
||
apiFetch('/inventory/upload', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ csv: csv }),
|
||
}).then(function (r) {
|
||
if (!r || r.error) {
|
||
resultDiv.innerHTML = '<span style="color:#F85149;">Error: ' + esc(r && r.error || 'unknown') + '</span>';
|
||
return;
|
||
}
|
||
var msg = '<span style="color:#3FB950;">✓ ' + r.inserted + ' nuevos, ' +
|
||
r.updated + ' actualizados';
|
||
if (r.skipped > 0) msg += ', ' + r.skipped + ' omitidos';
|
||
msg += '</span>';
|
||
if (r.errors && r.errors.length) {
|
||
msg += '<div style="margin-top:var(--space-2);color:var(--color-text-muted);font-size:var(--text-caption);">';
|
||
r.errors.slice(0, 5).forEach(function (e) {
|
||
msg += '<div>• ' + esc(e) + '</div>';
|
||
});
|
||
if (r.total_errors > 5) msg += '<div>(' + (r.total_errors - 5) + ' errores mas)</div>';
|
||
msg += '</div>';
|
||
}
|
||
resultDiv.innerHTML = msg;
|
||
});
|
||
};
|
||
|
||
// ── Generic modal close ──
|
||
window.closeModal = function (id) {
|
||
document.getElementById(id).classList.remove('open');
|
||
};
|
||
// Close modal on outside click
|
||
document.querySelectorAll('.modal-overlay').forEach(function (m) {
|
||
m.addEventListener('click', function (e) {
|
||
if (e.target === m) m.classList.remove('open');
|
||
});
|
||
});
|
||
|
||
// ── Boot ──
|
||
if (!TOKEN) {
|
||
window.location.href = '/pos/login';
|
||
} else {
|
||
detectRole();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|