Files
Autoparts-DB/pos/templates/marketplace.html
consultoria-as e00dce7d5a feat(pos): add gunicorn, marketplace B2B, and subscription billing (#7, #8, #12)
- Gunicorn production server with auto-scaled workers, run.sh, updated systemd service
- Marketplace B2B: cross-tenant inventory search, ordering, seller management with full UI
- Subscription billing: plan limits enforced on products/employees/branches, billing API + upgrade flow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 08:17:33 +00:00

460 lines
19 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="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);
color: var(--color-text-primary);
background-color: var(--color-bg-base);
overflow-x: hidden;
}
[data-theme="modern"] body {
background-image: radial-gradient(circle, var(--dot-grid-color) 1px, transparent 1px);
background-size: var(--dot-grid-size) var(--dot-grid-size);
}
.mp-container { max-width: 1200px; margin: 0 auto; padding: var(--space-6); }
.mp-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-6); flex-wrap: wrap; gap: var(--space-4); }
.mp-header h1 { font-family: var(--font-heading); font-size: var(--text-heading-lg); font-weight: var(--font-weight-bold); }
.mp-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); }
.mp-tab {
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-1);
cursor: pointer;
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
transition: all var(--duration-fast) var(--ease-in-out);
}
.mp-tab:hover { background: var(--color-surface-2); }
.mp-tab.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.search-bar { display: flex; gap: var(--space-3); margin-bottom: var(--space-5); }
.search-bar input {
flex: 1;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-body-sm);
font-family: var(--font-body);
background: var(--color-surface-1);
color: var(--color-text-primary);
}
.search-bar input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-alpha-20, rgba(26,115,232,0.2)); }
.search-bar select {
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-body-sm);
font-family: var(--font-body);
background: var(--color-surface-1);
color: var(--color-text-primary);
min-width: 180px;
}
.btn {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-in-out);
}
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-primary:hover { filter: brightness(1.1); }
.btn-sm { padding: var(--space-1) var(--space-3); font-size: var(--text-caption); }
.btn-success { background: var(--color-success, #34a853); color: #fff; }
.btn-danger { background: var(--color-error, #ea4335); color: #fff; }
.results-table { width: 100%; border-collapse: collapse; margin-bottom: var(--space-5); }
.results-table th, .results-table td {
padding: var(--space-3) var(--space-4);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.results-table th {
background: var(--color-surface-2);
font-weight: var(--font-weight-semibold);
font-size: var(--text-caption);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
}
.results-table tr:hover td { background: var(--color-surface-1); }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
}
.badge-pending { background: #fff3cd; color: #856404; }
.badge-confirmed { background: #cce5ff; color: #004085; }
.badge-shipped { background: #d4edda; color: #155724; }
.badge-delivered { background: #d1ecf1; color: #0c5460; }
.badge-cancelled { background: #f8d7da; color: #721c24; }
.cart-panel {
position: fixed; right: 0; top: 0; bottom: 0; width: 380px;
background: var(--color-surface-1);
border-left: 1px solid var(--color-border);
padding: var(--space-5);
display: none; flex-direction: column;
z-index: 100;
box-shadow: -4px 0 12px rgba(0,0,0,0.1);
}
.cart-panel.open { display: flex; }
.cart-panel h3 { margin-bottom: var(--space-4); font-family: var(--font-heading); }
.cart-items { flex: 1; overflow-y: auto; }
.cart-item {
display: flex; justify-content: space-between; align-items: center;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border);
font-size: var(--text-body-sm);
}
.cart-item button { background: none; border: none; color: var(--color-error, #ea4335); cursor: pointer; font-size: 16px; }
.cart-total { padding: var(--space-4) 0; font-weight: var(--font-weight-bold); font-size: var(--text-body-lg); border-top: 2px solid var(--color-border); }
.cart-actions { display: flex; gap: var(--space-3); margin-top: var(--space-4); }
.cart-actions .btn { flex: 1; }
.empty-state { text-align: center; padding: var(--space-8); color: var(--color-text-muted); }
.empty-state svg { margin-bottom: var(--space-4); opacity: 0.4; }
.pagination { display: flex; justify-content: center; gap: var(--space-2); margin-top: var(--space-4); }
.pagination button { padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface-1); cursor: pointer; }
.pagination button.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 768px) {
.cart-panel { width: 100%; }
.search-bar { flex-direction: column; }
.search-bar select { min-width: auto; }
}
</style>
</head>
<body>
<div class="mp-container">
<div class="mp-header">
<h1>Marketplace B2B</h1>
<div style="display:flex;gap:var(--space-3);align-items:center">
<button class="btn btn-primary" onclick="toggleCart()">
Carrito (<span id="cart-count">0</span>)
</button>
<a href="/pos/dashboard" class="btn" style="background:var(--color-surface-2);border:1px solid var(--color-border)">Volver</a>
</div>
</div>
<div class="mp-tabs">
<button class="mp-tab active" data-tab="search" onclick="switchTab('search')">Buscar Partes</button>
<button class="mp-tab" data-tab="orders" onclick="switchTab('orders')">Mis Pedidos</button>
<button class="mp-tab" data-tab="sellers" onclick="switchTab('sellers')">Proveedores</button>
</div>
<!-- Search Tab -->
<div id="tab-search">
<div class="search-bar">
<input type="text" id="search-input" placeholder="Buscar por numero de parte, nombre o marca..." />
<select id="seller-filter">
<option value="">Todos los proveedores</option>
</select>
<button class="btn btn-primary" onclick="doSearch()">Buscar</button>
</div>
<div id="search-results">
<div class="empty-state">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none"><circle cx="20" cy="20" r="16" stroke="currentColor" stroke-width="3"/><path d="M32 32L44 44" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
<p>Busca partes en el inventario de proveedores registrados.</p>
</div>
</div>
</div>
<!-- Orders Tab -->
<div id="tab-orders" style="display:none">
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)">
<select id="order-role" onchange="loadOrders()">
<option value="">Todos</option>
<option value="buyer">Como Comprador</option>
<option value="seller">Como Vendedor</option>
</select>
<select id="order-status" onchange="loadOrders()">
<option value="">Todos los estados</option>
<option value="pending">Pendiente</option>
<option value="confirmed">Confirmado</option>
<option value="shipped">Enviado</option>
<option value="delivered">Entregado</option>
<option value="cancelled">Cancelado</option>
</select>
</div>
<div id="orders-list">
<div class="empty-state"><p>Cargando pedidos...</p></div>
</div>
</div>
<!-- Sellers Tab -->
<div id="tab-sellers" style="display:none">
<div id="sellers-list">
<div class="empty-state"><p>Cargando proveedores...</p></div>
</div>
</div>
</div>
<!-- Cart Panel -->
<div class="cart-panel" id="cart-panel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4)">
<h3>Carrito de Pedido</h3>
<button onclick="toggleCart()" style="background:none;border:none;font-size:20px;cursor:pointer;color:var(--color-text-primary)">&times;</button>
</div>
<div class="cart-items" id="cart-items"></div>
<div class="cart-total">Total: $<span id="cart-total">0.00</span></div>
<div class="cart-actions">
<button class="btn btn-danger" onclick="clearCart()">Vaciar</button>
<button class="btn btn-success" onclick="submitOrder()">Enviar Pedido</button>
</div>
</div>
<script>
const TOKEN = localStorage.getItem('pos_token');
const HEADERS = { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' };
let cart = []; // { seller_id, seller_name, part_number, part_name, quantity, unit_price }
// ─── Tabs ──────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.mp-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('tab-search').style.display = tab === 'search' ? '' : 'none';
document.getElementById('tab-orders').style.display = tab === 'orders' ? '' : 'none';
document.getElementById('tab-sellers').style.display = tab === 'sellers' ? '' : 'none';
if (tab === 'orders') loadOrders();
if (tab === 'sellers') loadSellers();
}
// ─── Search ─────────────────────────────────────
function doSearch() {
const q = document.getElementById('search-input').value.trim();
if (q.length < 2) return;
const sellerId = document.getElementById('seller-filter').value;
let url = '/pos/api/marketplace/search?q=' + encodeURIComponent(q);
if (sellerId) url += '&seller_id=' + sellerId;
fetch(url, { headers: HEADERS }).then(r => r.json()).then(res => {
const el = document.getElementById('search-results');
if (!res.data || !res.data.length) {
el.innerHTML = '<div class="empty-state"><p>No se encontraron resultados.</p></div>';
return;
}
let html = '<table class="results-table"><thead><tr><th>Proveedor</th><th>No. Parte</th><th>Nombre</th><th>Marca</th><th>Precio</th><th>Stock</th><th></th></tr></thead><tbody>';
res.data.forEach(item => {
html += `<tr>
<td>${esc(item.seller_name)}</td>
<td><strong>${esc(item.part_number)}</strong></td>
<td>${esc(item.name)}</td>
<td>${esc(item.brand || '')}</td>
<td>$${fmt(item.price)}</td>
<td>${item.stock}</td>
<td><button class="btn btn-sm btn-primary" onclick="addToCart(${item.seller_id}, '${esc(item.seller_name)}', '${esc(item.part_number)}', '${esc(item.name)}', ${item.price})">+ Carrito</button></td>
</tr>`;
});
html += '</tbody></table>';
if (res.pagination && res.pagination.pages > 1) {
html += '<div class="pagination">';
for (let i = 1; i <= Math.min(res.pagination.pages, 10); i++) {
html += `<button class="${i === res.pagination.page ? 'active' : ''}" onclick="searchPage(${i})">${i}</button>`;
}
html += '</div>';
}
el.innerHTML = html;
}).catch(() => {
document.getElementById('search-results').innerHTML = '<div class="empty-state"><p>Error al buscar.</p></div>';
});
}
document.getElementById('search-input').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
// ─── Cart ────────────────────────────────────────
function addToCart(sellerId, sellerName, partNumber, partName, price) {
const existing = cart.find(c => c.seller_id === sellerId && c.part_number === partNumber);
if (existing) { existing.quantity++; }
else { cart.push({ seller_id: sellerId, seller_name: sellerName, part_number: partNumber, part_name: partName, quantity: 1, unit_price: price }); }
renderCart();
}
function removeFromCart(idx) { cart.splice(idx, 1); renderCart(); }
function clearCart() { cart = []; renderCart(); }
function toggleCart() {
document.getElementById('cart-panel').classList.toggle('open');
}
function renderCart() {
const el = document.getElementById('cart-items');
document.getElementById('cart-count').textContent = cart.length;
if (!cart.length) {
el.innerHTML = '<div class="empty-state"><p>Carrito vacio</p></div>';
document.getElementById('cart-total').textContent = '0.00';
return;
}
let total = 0;
let html = '';
cart.forEach((item, idx) => {
const sub = item.quantity * item.unit_price;
total += sub;
html += `<div class="cart-item">
<div>
<div><strong>${esc(item.part_number)}</strong></div>
<div style="font-size:11px;color:var(--color-text-muted)">${esc(item.seller_name)}</div>
<div>${item.quantity} x $${fmt(item.unit_price)} = $${fmt(sub)}</div>
</div>
<button onclick="removeFromCart(${idx})">&times;</button>
</div>`;
});
el.innerHTML = html;
document.getElementById('cart-total').textContent = fmt(total);
}
function submitOrder() {
if (!cart.length) return alert('El carrito esta vacio');
// Group cart items by seller
const bySeller = {};
cart.forEach(item => {
if (!bySeller[item.seller_id]) bySeller[item.seller_id] = { seller_id: item.seller_id, seller_name: item.seller_name, items: [] };
bySeller[item.seller_id].items.push(item);
});
const promises = Object.values(bySeller).map(group => {
return fetch('/pos/api/marketplace/order', {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({
seller_id: group.seller_id,
items: group.items.map(i => ({
part_number: i.part_number,
part_name: i.part_name,
quantity: i.quantity,
unit_price: i.unit_price,
})),
}),
}).then(r => r.json());
});
Promise.all(promises).then(results => {
const errors = results.filter(r => r.error);
if (errors.length) {
alert('Algunos pedidos fallaron: ' + errors.map(e => e.error).join(', '));
} else {
alert('Pedido(s) creado(s) exitosamente: ' + results.map(r => '#' + r.id).join(', '));
cart = [];
renderCart();
toggleCart();
}
}).catch(() => alert('Error al enviar pedido'));
}
// ─── Orders ──────────────────────────────────────
function loadOrders() {
const role = document.getElementById('order-role').value;
const status = document.getElementById('order-status').value;
let url = '/pos/api/marketplace/orders?';
if (role) url += 'role=' + role + '&';
if (status) url += 'status=' + status + '&';
fetch(url, { headers: HEADERS }).then(r => r.json()).then(res => {
const el = document.getElementById('orders-list');
if (!res.data || !res.data.length) {
el.innerHTML = '<div class="empty-state"><p>No hay pedidos.</p></div>';
return;
}
let html = '<table class="results-table"><thead><tr><th>#</th><th>Comprador</th><th>Vendedor</th><th>Total</th><th>Estado</th><th>Fecha</th><th></th></tr></thead><tbody>';
res.data.forEach(o => {
html += `<tr>
<td>${o.id}</td>
<td>${esc(o.buyer_name || '')}</td>
<td>${esc(o.seller_name || '')}</td>
<td>$${fmt(o.total)}</td>
<td><span class="badge badge-${o.status}">${o.status}</span></td>
<td>${new Date(o.created_at).toLocaleDateString('es-MX')}</td>
<td>
<button class="btn btn-sm" style="background:var(--color-surface-2);border:1px solid var(--color-border)" onclick="viewOrderItems(${o.id})">Ver</button>
</td>
</tr>`;
});
html += '</tbody></table>';
el.innerHTML = html;
}).catch(() => {
document.getElementById('orders-list').innerHTML = '<div class="empty-state"><p>Error al cargar pedidos.</p></div>';
});
}
function viewOrderItems(orderId) {
fetch('/pos/api/marketplace/orders/' + orderId + '/items', { headers: HEADERS })
.then(r => r.json()).then(res => {
if (!res.data || !res.data.length) return alert('Sin articulos');
let msg = 'Articulos del pedido #' + orderId + ':\n\n';
res.data.forEach(i => {
msg += `${i.part_number} - ${i.part_name}: ${i.quantity} x $${fmt(i.unit_price)} = $${fmt(i.subtotal)}\n`;
});
alert(msg);
});
}
// ─── Sellers ─────────────────────────────────────
function loadSellers() {
fetch('/pos/api/marketplace/sellers', { headers: HEADERS }).then(r => r.json()).then(res => {
const el = document.getElementById('sellers-list');
const sel = document.getElementById('seller-filter');
if (!res.data || !res.data.length) {
el.innerHTML = '<div class="empty-state"><p>No hay proveedores registrados.</p></div>';
return;
}
// Update filter dropdown
sel.innerHTML = '<option value="">Todos los proveedores</option>';
res.data.forEach(s => {
sel.innerHTML += `<option value="${s.id}">${esc(s.name)}</option>`;
});
let html = '<table class="results-table"><thead><tr><th>Nombre</th><th>RFC</th><th>Subdominio</th></tr></thead><tbody>';
res.data.forEach(s => {
html += `<tr><td><strong>${esc(s.name)}</strong></td><td>${esc(s.rfc || '-')}</td><td>${esc(s.subdomain || '-')}</td></tr>`;
});
html += '</tbody></table>';
el.innerHTML = html;
}).catch(() => {
document.getElementById('sellers-list').innerHTML = '<div class="empty-state"><p>Error al cargar proveedores.</p></div>';
});
}
// ─── Helpers ─────────────────────────────────────
function fmt(n) { return (parseFloat(n) || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// Init: load sellers for filter
fetch('/pos/api/marketplace/sellers', { headers: HEADERS }).then(r => r.json()).then(res => {
const sel = document.getElementById('seller-filter');
if (res.data) res.data.forEach(s => {
sel.innerHTML += `<option value="${s.id}">${esc(s.name)}</option>`;
});
}).catch(() => {});
renderCart();
</script>
</body>
</html>