Files
Autoparts-DB/pos/templates/marketplace.html
consultoria-as afb3b2405c feat(voice): implementa voz y TTS en chats POS y dashboard
- Agrega TTS (speechSynthesis) a chat.js del POS para leer respuestas IA
- Copia lógica de voz completa (STT + TTS) a dashboard/chat-public.js
- Extiende estilos TTS en chat.css y chat-public.css
- Agrega chat widget a 13 templates POS que no lo tenían
- Corrige duplicado de chat.css en diagrams.html
- Minifica assets actualizados
- 73/73 tests pasan
2026-04-28 00:53:57 +00:00

519 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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/chat.css" />
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/sidebar.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" />
<link rel="stylesheet" href="/pos/static/css/marketplace.css">
</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&#10;AB-123,5,150.50&#10;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>
<script src="/pos/static/js/chat.js" defer></script>
</body>
</html>