feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes

- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
This commit is contained in:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -188,10 +188,10 @@
<!-- Tabs -->
<div class="tabs-row" role="tablist">
<button class="tab-btn is-active" role="tab" aria-selected="true" onclick="switchTab('cxc')">
Ctas. por Cobrar <span class="tab-btn__badge">23</span>
Ctas. por Cobrar <span class="tab-btn__badge" id="badge-cxc">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" onclick="switchTab('cxp')">
Ctas. por Pagar <span class="tab-btn__badge--alert tab-btn__badge">3</span>
Ctas. por Pagar <span class="tab-btn__badge--alert tab-btn__badge" id="badge-cxp">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" onclick="switchTab('balance')">
Balance General
@@ -498,5 +498,31 @@
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script>
// Load accounting stats for tab badges
async function loadAccountingStats() {
const token = localStorage.getItem('pos_token') || '';
try {
const res = await fetch('/pos/api/accounting/stats', {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
});
if (!res.ok) return;
const data = await res.json();
const map = {
'badge-cxc': data.cuentas_cobrar,
'badge-cxp': data.cuentas_pagar
};
Object.entries(map).forEach(function([id, val]) {
const el = document.getElementById(id);
if (el) el.textContent = val || 0;
});
} catch (e) {
console.error('Failed to load accounting stats:', e);
}
}
window.loadAccountingStats = loadAccountingStats;
loadAccountingStats();
</script>
</body>
</html>

View File

@@ -420,6 +420,10 @@
<div class="credit-metric__label">Utilizado</div>
<div class="credit-metric__value used" id="detailCreditUsed">$31,500</div>
</div>
<div class="credit-metric">
<div class="credit-metric__label">Descuento Max</div>
<div class="credit-metric__value" id="detailMaxDiscount">0%</div>
</div>
</div>
<div class="credit-progress">
<div class="credit-progress__labels">
@@ -611,7 +615,7 @@
</div>
<div class="panel-footer">
<button class="btn-edit" onclick="if(typeof Customers!=='undefined') Customers.editCurrent();">Editar Cliente</button>
<button class="btn-sale" onclick="window.location.href='/pos/';">Nueva Venta</button>
<button class="btn-sale" onclick="window.location.href='/pos/sale';">Nueva Venta</button>
</div>
</div>

View File

@@ -13,7 +13,7 @@
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="stylesheet" href="/pos/static/css/inventory.css">
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=4">
</head>
<body>
@@ -306,10 +306,18 @@
]);
}
</script>
<button class="btn btn--ghost btn--sm" onclick="showTierDiscountModal()">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
</button>
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
<button class="btn btn--sm btn--meli" id="btnPublishML" style="display:none;" onclick="openMeliPublishModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Publicar en ML <span id="meliSelectedCountBadge" style="background:#2D3277;color:#FFE600;border-radius:10px;padding:0 6px;font-size:11px;margin-left:4px;">0</span>
</button>
</div>
<div class="table-wrapper">
@@ -317,6 +325,7 @@
<table class="data-table" id="stockTable">
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="selectAllItems" onclick="toggleSelectAllItems()" title="Seleccionar todos" /></th>
<th style="font-size:var(--text-caption);color:var(--color-text-muted);">ID</th>
<th>Barcode</th>
<th>No. Parte</th>
@@ -324,9 +333,9 @@
<th>Marca</th>
<th style="text-align:right">Stock</th>
<th style="text-align:right">Costo</th>
<th style="text-align:right">Precio 1</th>
<th style="text-align:right">Precio 2</th>
<th style="text-align:right">Precio 3</th>
<th style="text-align:right">Mostrador</th>
<th style="text-align:right">Taller</th>
<th style="text-align:right">Mayoreo</th>
<th>Ubicación</th>
<th>Acciones</th>
</tr>
@@ -689,9 +698,7 @@
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 3</label><input type="number" id="newPrice3" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
@@ -809,6 +816,73 @@
</div>
</div>
<!-- Tier Discounts Modal -->
<div class="inv-modal-overlay" id="tierDiscountModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3>Descuentos por Tipo de Cliente</h3>
<button class="inv-modal__close" onclick="closeTierDiscountModal()">&times;</button>
</div>
<div class="inv-modal__body">
<p style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-4);">Estos descuentos se aplican automáticamente a todos los productos al calcular precios de Taller y Mayoreo.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="inv-field"><label>Descuento Taller (%)</label><input type="number" id="tierDisc2" step="0.1" min="0" max="100" placeholder="15" /></div>
<div class="inv-field"><label>Descuento Mayoreo (%)</label><input type="number" id="tierDisc3" step="0.1" min="0" max="100" placeholder="25" /></div>
</div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeTierDiscountModal()">Cancelar</button>
<button class="btn btn--primary" onclick="saveTierDiscounts()">Guardar</button>
</div>
</div>
</div>
<!-- ══════════ Publicar en MercadoLibre Modal ══════════ -->
<div class="inv-modal-overlay" id="meliPublishModal">
<div class="inv-modal inv-modal--wide">
<div class="inv-modal__header">
<h3>Publicar en MercadoLibre</h3>
<button class="inv-modal__close" onclick="closeMeliPublishModal()">&times;</button>
</div>
<div class="inv-modal__body">
<div id="meliPublishSelectedCount" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-3);">0 productos seleccionados</div>
<div id="meliPublishItemsPreview" style="max-height:200px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-4);">
<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Selecciona productos del inventario para ver el preview.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:var(--space-4);">
<div class="inv-field">
<label>Categoría ML *</label>
<div style="position:relative;">
<input type="text" id="meliCategorySearch" placeholder="Buscar categoría..." oninput="searchMeliCategories()" onkeydown="handleMeliCatKeydown(event)" autocomplete="off" />
<div id="meliCategoryResults"></div>
</div>
<input type="hidden" id="meliCategoryId" />
</div>
<div class="inv-field">
<label>Tipo de Publicación</label>
<select id="meliListingType">
<option value="gold_special">Gold Special</option>
<option value="gold_pro">Gold Pro</option>
<option value="bronze">Bronce (gratis)</option>
</select>
</div>
<div class="inv-field">
<label>Modo de Envío</label>
<select id="meliShippingMode">
<option value="me2" selected>MercadoEnvíos (me2)</option>
</select>
<small style="color:var(--color-text-muted);font-size:var(--text-caption);">Tu cuenta requiere ME2 obligatoriamente.</small>
</div>
</div>
<div id="meliPublishResult" style="min-height:1.5em;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeMeliPublishModal()">Cancelar</button>
<button class="btn btn--meli" id="meliPublishBtn" onclick="executeMeliPublish()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Publicar</button>
</div>
</div>
</div>
<!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
<span class="banner__icon"></span>
@@ -821,7 +895,7 @@
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/inventory.js?v=5" defer></script>
<script src="/pos/static/js/inventory.js?v=10" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -301,16 +301,16 @@
<!-- Tabs Row -->
<div class="tabs-row" role="tablist" aria-label="Módulos de Facturación">
<button class="tab-btn is-active" role="tab" aria-selected="true" aria-controls="panel-facturas" id="tab-facturas" onclick="switchTab('facturas')">
Facturas <span class="tab-btn__badge">247</span>
Facturas <span class="tab-btn__badge" id="badge-facturas">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-notas" id="tab-notas" onclick="switchTab('notas')">
Notas de Crédito <span class="tab-btn__badge">8</span>
Notas de Crédito <span class="tab-btn__badge" id="badge-notas-credito">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-complementos" id="tab-complementos" onclick="switchTab('complementos')">
Complementos de Pago <span class="tab-btn__badge">12</span>
Complementos de Pago <span class="tab-btn__badge" id="badge-complementos">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-cancelaciones" id="tab-cancelaciones" onclick="switchTab('cancelaciones')">
Cancelaciones <span class="tab-btn__badge tab-btn__badge--warn">6</span>
Cancelaciones <span class="tab-btn__badge tab-btn__badge--warn" id="badge-cancelaciones">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-config" id="tab-config" onclick="switchTab('config')">
Configuración CFDI
@@ -1060,5 +1060,33 @@
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script>
// Load invoicing stats for tab badges
async function loadInvoicingStats() {
const token = localStorage.getItem('pos_token') || '';
try {
const res = await fetch('/pos/api/invoicing/stats', {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
});
if (!res.ok) return;
const data = await res.json();
const map = {
'badge-facturas': data.facturas,
'badge-notas-credito': data.notas_credito,
'badge-complementos': data.complementos,
'badge-cancelaciones': data.cancelaciones
};
Object.entries(map).forEach(function([id, val]) {
const el = document.getElementById(id);
if (el) el.textContent = val || 0;
});
} catch (e) {
console.error('Failed to load invoicing stats:', e);
}
}
window.loadInvoicingStats = loadInvoicingStats;
loadInvoicingStats();
</script>
</body>
</html>

View File

@@ -291,6 +291,13 @@
btnLogin.disabled = !canLogin;
}
/* ------------------------------------------------------------------
LOGIN BUTTON CLICK
------------------------------------------------------------------ */
btnLogin.addEventListener('click', function() {
triggerLogin();
});
/* ------------------------------------------------------------------
TRIGGER LOGIN (demo)
------------------------------------------------------------------ */

View File

@@ -57,10 +57,11 @@
<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>
<textarea id="csvText" placeholder="part_number,stock,price,name&#10;AB-123,5,150.50,Filtro de aceite&#10;CD-456,12,89.00,Balata delantera"></textarea>
<div class="hint">
Columnas requeridas: <code>part_number, stock, price</code>.
Opcionales: <code>min_order, warehouse_location, currency</code>.
Opcionales: <code>name, min_order, warehouse_location, currency</code>.
<br>Si la parte no existe en el catálogo, se crea automáticamente como <em>seller listing</em>.
</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>
@@ -205,8 +206,18 @@
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>' +
var isOem = p.listing_type === 'oem';
var badge = isOem
? '<span style="background:#3FB95020;color:#3FB950;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Catálogo</span>'
: '<span style="background:#F5A62320;color:#F5A623;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Listing</span>';
var onclick = isOem
? 'openPartDetail(' + p.id + ')'
: 'openListingDetail(' + p.id + ')';
return '<div class="part-card" onclick="' + onclick + '">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-1);">' +
'<div class="part-card__oem">' + esc(p.part_number) + '</div>' +
badge +
'</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'<div class="part-card__meta">' +
'<span class="price-range">' + priceStr + '</span>' +
@@ -248,7 +259,7 @@
'</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>' +
'<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 + ', null, ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
'</div>' +
'</div>';
});
@@ -257,7 +268,38 @@
});
};
window.createOrderFor = function (partId, bodegaId, bodegaName, price) {
window.openListingDetail = function (wiId) {
var modal = document.getElementById('partModal');
modal.classList.add('open');
document.getElementById('partModalBody').innerHTML = 'Cargando bodegas...';
apiFetch('/inventory/listing/' + wiId).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(null, ' + wiId + ', ' + 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, wiId, bodegaId, bodegaName, price) {
var qty = prompt('Cantidad a ordenar para "' + bodegaName + '":', '1');
if (!qty) return;
qty = parseInt(qty);
@@ -265,12 +307,22 @@
var notes = prompt('Notas para la bodega (opcional):', '') || '';
var item = { quantity: qty, unit_price: price };
if (partId) {
item.part_id = partId;
} else if (wiId) {
item.wi_id = wiId;
} else {
alert('Error: se requiere part_id o wi_id');
return;
}
// Create PO draft
apiFetch('/orders', {
method: 'POST',
body: JSON.stringify({
bodega_id: bodegaId,
items: [{ part_id: partId, quantity: qty, unit_price: price }],
items: [item],
delivery_method: 'pickup',
buyer_notes: notes,
}),
@@ -482,6 +534,10 @@
r.updated + ' actualizados';
if (r.skipped > 0) msg += ', ' + r.skipped + ' omitidos';
msg += '</span>';
if (r.oem_count !== undefined || r.seller_count !== undefined) {
msg += '<div style="margin-top:var(--space-2);font-size:var(--text-caption);color:var(--color-text-muted);">' +
(r.oem_count || 0) + ' catálogo OEM · ' + (r.seller_count || 0) + ' seller listings</div>';
}
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) {

View File

@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="es" data-theme="industrial">
<head>
<script>(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>MercadoLibre — 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="stylesheet" href="/pos/static/css/inventory.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<style>
.meli-status { display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:20px;font-size:12px;font-weight:600; }
.meli-status--active { background:#d4edda;color:#155724; }
.meli-status--paused { background:#fff3cd;color:#856404; }
.meli-status--closed { background:#f8d7da;color:#721c24; }
.meli-status--pending { background:#e2e3e5;color:#383d41; }
.meli-card { background:var(--color-surface-1);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-4); }
.meli-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:var(--space-4); }
.meli-connect-btn { display:inline-flex;align-items:center;gap:8px;padding:10px 20px;background:#FFE600;color:#2D3277;border:none;border-radius:var(--radius-md);font-weight:700;cursor:pointer; }
.meli-connect-btn:hover { filter:brightness(0.95); }
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
.meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; }
.meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); }
</style>
</head>
<body>
<!-- =========================================================================
THEME SWITCHER BAR
========================================================================= -->
<header class="theme-bar" role="banner">
<div class="theme-bar__left">
<div class="theme-bar__store">
<span class="theme-bar__dot"></span>
Nexus Autoparts
</div>
<div class="theme-bar__sep"></div>
<span class="theme-bar__label">MercadoLibre — Integración</span>
</div>
<div class="theme-bar__right">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
<span class="theme-btn__swatch"></span>
Industrial
</button>
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
<span class="theme-btn__swatch"></span>
Moderno
</button>
</div>
</header>
<!-- =========================================================================
APP SHELL
========================================================================= -->
<div class="app-shell">
<!-- -----------------------------------------------------------------------
SIDEBAR NAVIGATION
----------------------------------------------------------------------- -->
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
<div class="sidebar__brand">
<div class="brand-logo">NA</div>
<div class="brand-name">
<span class="brand-name__primary">Nexus</span>
<span class="brand-name__sub">Autoparts POS</span>
</div>
</div>
<nav class="sidebar__nav">
<div class="nav-section-label">Principal</div>
<a class="nav-item" href="/pos/dashboard">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a class="nav-item" href="/pos/sale">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>POS</span>
</a>
<a class="nav-item" href="/pos/catalog">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span>Catálogo</span>
</a>
<a class="nav-item" href="/pos/inventory">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Inventario</span>
</a>
<div class="nav-section-label">Gestión</div>
<a class="nav-item" href="/pos/customers">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Clientes</span>
</a>
<a class="nav-item" href="/pos/marketplace">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
<span>Marketplace B2B</span>
</a>
<a class="nav-item is-active" href="/pos/marketplace-external" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>MercadoLibre</span>
</a>
<a class="nav-item" href="/pos/invoicing">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>Facturación</span>
</a>
<a class="nav-item" href="/pos/config">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
<span>Configuración</span>
</a>
</nav>
<div class="sidebar__footer">
<div class="sidebar__user-avatar" id="sidebarAvatar">U</div>
<div class="sidebar__user-info">
<div class="sidebar__user-name" id="sidebarName">Usuario</div>
<div class="sidebar__user-role" id="sidebarRole"></div>
</div>
</div>
</aside>
<!-- -----------------------------------------------------------------------
MAIN CONTENT
----------------------------------------------------------------------- -->
<main class="main" role="main">
<!-- Page Header -->
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Marketplace</span>
<h1 class="page-header__title">MercadoLibre</h1>
</div>
<div class="page-header__actions">
<a class="btn btn--ghost" href="/pos/dashboard">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Dashboard
</a>
</div>
</div>
<!-- Tab Bar -->
<div class="tab-bar">
<button class="tab-btn is-active" role="tab" aria-selected="true" aria-controls="panel-config" onclick="switchTab('config')">
Configuración
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-listings" onclick="switchTab('listings')">
Publicaciones
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-orders" onclick="switchTab('orders')">
Órdenes
</button>
</div>
<!-- Tab Panels -->
<div class="tab-panels" id="tab-panels">
<!-- ══════════ TAB: Configuración ══════════ -->
<div class="tab-panel is-active" id="panel-config" role="tabpanel">
<div class="meli-card" style="max-width:600px;">
<h3 style="margin:0 0 var(--space-4);font-family:var(--font-heading);">Conexión con MercadoLibre</h3>
<div id="configStatus">
<p>Cargando estado...</p>
</div>
<div id="configForm" style="display:none;margin-top:var(--space-4);">
<p style="margin-bottom:var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-secondary);">
Para conectar, necesitas una <strong>aplicación de MercadoLibre</strong>.
Ve a <a href="https://developers.mercadolibre.com.mx" target="_blank">developers.mercadolibre.com.mx</a>
y crea una app. Luego pega los datos aquí:
</p>
<div class="meli-config-row">
<div>
<label>Client ID</label>
<input type="text" id="cfgClientId" placeholder="Tu App ID" style="width:200px;" />
</div>
<div>
<label>Client Secret</label>
<input type="password" id="cfgClientSecret" placeholder="Tu Secret Key" style="width:200px;" />
</div>
</div>
<div class="meli-config-row">
<div>
<label>Categoría Default</label>
<input type="text" id="cfgCategory" placeholder="MLM1747" style="width:150px;" />
</div>
<div>
<label>Modo de Envío</label>
<select id="cfgShipping">
<option value="me2">MercadoEnvíos (me2)</option>
<option value="custom">Propio (custom)</option>
</select>
</div>
</div>
<button class="meli-connect-btn" onclick="startOAuth()">🔗 Conectar con MercadoLibre</button>
</div>
<div id="configConnected" style="display:none;margin-top:var(--space-4);">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:var(--space-3);">
<div style="width:48px;height:48px;border-radius:50%;background:#FFE600;display:flex;align-items:center;justify-content:center;font-weight:800;color:#2D3277;">ML</div>
<div>
<div style="font-weight:700;" id="connectedNickname">Usuario ML</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);" id="connectedSite">MLM</div>
</div>
</div>
<button class="btn btn--danger btn--sm" onclick="disconnectMeli()">Desconectar</button>
</div>
</div>
</div>
<!-- ══════════ TAB: Publicaciones ══════════ -->
<div class="tab-panel" id="panel-listings" role="tabpanel">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="listingSearch" placeholder="Buscar publicación..." oninput="filterListings()" />
</div>
<select class="select-filter" id="listingStatusFilter" onchange="filterListings()">
<option value="">Todos los estados</option>
<option value="active">Activas</option>
<option value="paused">Pausadas</option>
<option value="closed">Cerradas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadListings()">🔄 Actualizar</button>
</div>
<div id="listingsContainer" class="meli-grid"></div>
<div id="listingsPagination" class="table-footer" style="margin-top:var(--space-4);"></div>
</div>
<!-- ══════════ TAB: Órdenes ══════════ -->
<div class="tab-panel" id="panel-orders" role="tabpanel">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="orderSearch" placeholder="Buscar orden..." oninput="filterOrders()" />
</div>
<select class="select-filter" id="orderStatusFilter" onchange="filterOrders()">
<option value="">Todos los estados</option>
<option value="pending">Pendientes</option>
<option value="confirmed">Confirmadas</option>
<option value="packed">Empacadas</option>
<option value="shipped">Enviadas</option>
<option value="delivered">Entregadas</option>
<option value="cancelled">Canceladas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadOrders()">🔄 Actualizar</button>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Orden ML</th>
<th>Comprador</th>
<th style="text-align:right">Total</th>
<th>Estado Nexus</th>
<th>Fecha</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="ordersTableBody"></tbody>
</table>
<div id="ordersPagination" class="table-footer"></div>
</div>
</div>
</div><!-- /tab-panels -->
</main>
</div><!-- /app-shell -->
<!-- ══════════ Order Detail Modal ══════════ -->
<div class="inv-modal-overlay" id="orderModal">
<div class="inv-modal inv-modal--wide">
<div class="inv-modal__header">
<h3>Detalle de Orden ML</h3>
<button class="inv-modal__close" onclick="closeModal('orderModal')">&times;</button>
</div>
<div class="inv-modal__body" id="orderModalBody">cargando...</div>
<div class="inv-modal__footer" id="orderModalFooter"></div>
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=3" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -32,6 +32,10 @@
<span id="statusClock"></span>
</div>
<div class="status-bar__right">
<a href="/pos/dashboard" class="btn btn--ghost btn--sm" id="backToSystemBtn" style="margin-right:8px;display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Sistema
</a>
<div class="status-bar__user" aria-label="Usuario activo">
<div class="status-bar__user-avatar" aria-hidden="true">--</div>
<span id="employeeName">Empleado</span>
@@ -203,7 +207,7 @@
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
<button class="btn-secondary-action danger" id="btnCancelSale" title="Cancelar (Esc)">Cancelar</button>
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
</div>
</div>
</aside>
@@ -233,14 +237,14 @@
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
</div>
<div class="fkey-sep"></div>
<div class="fkey" title="Cantidad +/-">
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
</div>
<div class="fkey" title="Descuento">
<div class="fkey" onclick="POS.applyDiscount()" title="Descuento">
<span class="fkey-key">*</span><span class="fkey-label">Descuento</span>
</div>
<div class="fkey-sep"></div>
<div class="fkey" id="fkeyEsc" title="Cancelar">
<div class="fkey" id="fkeyEsc" onclick="POS.openCancelModal()" title="Cancelar">
<span class="fkey-key">Esc</span><span class="fkey-label">Cancelar</span>
</div>
</div>
@@ -563,7 +567,7 @@
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/push.js" defer></script>
<script src="/pos/static/js/printer.js" defer></script>
<script src="/pos/static/js/pos.js?v=4" defer></script>
<script src="/pos/static/js/pos.js?v=5" defer></script>
<script>
// Cancel sale button wiring
@@ -577,11 +581,7 @@
closeCancelModal();
// Clear cart via POS module
if (typeof POS !== 'undefined') {
// Clear each item
const tbody = document.getElementById('cartBody');
while (tbody && tbody.firstChild) {
POS.removeFromCart(0);
}
POS.clearCart();
POS.clearCustomer();
}
});
@@ -614,6 +614,20 @@
}
updateClock();
setInterval(updateClock, 30000);
// Show "Back to System" button when NOT in kiosk mode
(function checkKiosk() {
var btn = document.getElementById('backToSystemBtn');
if (!btn) return;
function showIfNotKiosk() {
try {
var isKiosk = window.isKioskEnabled && window.isKioskEnabled();
btn.style.display = isKiosk ? 'none' : 'inline-flex';
} catch (e) { btn.style.display = 'inline-flex'; }
}
showIfNotKiosk();
setInterval(showIfNotKiosk, 2000);
})();
</script>
<script src="/pos/static/js/chat.js" defer></script>

View File

@@ -65,7 +65,9 @@
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;font-family:var(--font-mono);font-weight:700;">$' + fmt(q.total) + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + statusBadge + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;color:var(--color-text-muted);">' + dateStr + '</td>';
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:4px;" onmouseover="this.style.color=\'#F85149\';this.style.background=\'rgba(248,81,73,0.1)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.background=\'none\'">🗑️</button></td>';
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:var(--color-bg-overlay);border:1.5px solid var(--color-border);color:var(--color-text-muted);cursor:pointer;font-size:13px;padding:6px 10px;border-radius:var(--radius-md);display:inline-flex;align-items:center;gap:4px;transition:var(--transition-fast);" onmouseover="this.style.color=\'var(--color-error)\';this.style.borderColor=\'var(--color-error)\';this.style.background=\'rgba(248,81,73,0.08)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.borderColor=\'var(--color-border)\';this.style.background=\'var(--color-bg-overlay)\'">';
html += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
html += 'Eliminar</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
@@ -114,15 +116,33 @@
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
html += '</div>';
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;flex-wrap:wrap;">';
html += '<div style="margin-top:var(--space-5);">';
// Primary actions
if (q.status === 'active') {
html += '<button class="btn btn--ghost" onclick="editQuote(' + q.id + ')" style="color:#4f46e5;">Editar</button>';
html += '<button class="btn btn--ghost" onclick="convertQuote(' + q.id + ')" style="color:#059669;">Convertir a venta</button>';
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')">Compartir link</button>';
html += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3);">';
html += '<button class="btn btn--primary" onclick="convertQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;">';
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>';
html += 'Convertir a Venta</button>';
html += '<button class="btn btn--secondary" onclick="editQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;">';
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
html += 'Editar</button>';
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;border:1.5px solid var(--color-border);">';
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
html += 'Compartir</button>';
html += '</div>';
}
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
// Secondary actions
html += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;justify-content:flex-end;">';
html += '<button class="btn btn--ghost" onclick="window.print()" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>';
html += 'Imprimir</button>';
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
html += 'Exportar CSV</button>';
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;color:var(--color-error);">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
html += 'Eliminar</button>';
html += '</div>';
html += '</div>';
document.getElementById('quoteDetail').innerHTML = html;
@@ -142,69 +162,83 @@
} else {
alert('Error: ' + (d.error || 'desconocido'));
}
});
})
.catch(function(err) { alert('Error de red al eliminar: ' + err.message); });
};
window.editQuote = function(id) {
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
if (!q.items) { alert('Error cargando cotización'); return; }
var cartItems = q.items.map(function(it) {
return {
inventory_id: it.inventory_id,
part_number: it.part_number,
name: it.name,
quantity: it.quantity,
unit_price: it.unit_price,
discount_pct: it.discount_pct,
tax_rate: it.tax_rate
};
});
localStorage.setItem('pos_edit_quote_id', id);
localStorage.setItem('pos_edit_quote_customer_id', q.customer_id || '');
localStorage.setItem('pos_edit_quote_notes', q.notes || '');
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos';
});
try {
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
if (!q.items) { alert('Error cargando cotización'); return; }
var cartItems = q.items.map(function(it) {
return {
inventory_id: it.inventory_id,
part_number: it.part_number,
name: it.name,
quantity: it.quantity,
unit_price: it.unit_price,
discount_pct: it.discount_pct,
tax_rate: it.tax_rate
};
});
localStorage.setItem('pos_edit_quote_id', id);
localStorage.setItem('pos_edit_quote_customer_id', q.customer_id || '');
localStorage.setItem('pos_edit_quote_notes', q.notes || '');
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos/sale';
})
.catch(function(err) { alert('Error al cargar para editar: ' + err.message); });
} catch (e) { alert('Excepcion en editQuote: ' + e.message); }
};
window.convertQuote = function(id) {
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
if (!q.items) { alert('Error cargando cotización'); return; }
var cartItems = q.items.map(function(it) {
return {
inventory_id: it.inventory_id,
part_number: it.part_number,
name: it.name,
quantity: it.quantity,
unit_price: it.unit_price,
discount_pct: it.discount_pct,
tax_rate: it.tax_rate
};
});
localStorage.setItem('pos_convert_quote_id', id);
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos';
});
try {
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
if (!q.items) { alert('Error cargando cotización'); return; }
var cartItems = q.items.map(function(it) {
return {
inventory_id: it.inventory_id,
part_number: it.part_number,
name: it.name,
quantity: it.quantity,
unit_price: it.unit_price,
discount_pct: it.discount_pct,
tax_rate: it.tax_rate
};
});
localStorage.setItem('pos_convert_quote_id', id);
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos/sale';
})
.catch(function(err) { alert('Error al cargar para convertir: ' + err.message); });
} catch (e) { alert('Excepcion en convertQuote: ' + e.message); }
};
window.shareQuote = function(id) {
fetch(API + '/quotations/' + id + '/share', { method: 'POST', headers: headers() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.url) {
navigator.clipboard.writeText(d.url).then(function() {
alert('Link copiado al portapapeles:\n' + d.url);
}).catch(function() {
prompt('Copia este link:', d.url);
});
} else {
alert('Error: ' + (d.error || 'desconocido'));
}
});
try {
fetch(API + '/quotations/' + id + '/share', { method: 'POST', headers: headers() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.url) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(d.url).then(function() {
alert('Link copiado al portapapeles:\n' + d.url);
}).catch(function() {
prompt('Copia este link:', d.url);
});
} else {
prompt('Copia este link:', d.url);
}
} else {
alert('Error del servidor: ' + (d.error || 'desconocido'));
}
})
.catch(function(err) { alert('Error de red al compartir: ' + err.message); });
} catch (e) { alert('Excepcion en shareQuote: ' + e.message); }
};
// Close modal on outside click

View File

@@ -131,7 +131,7 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
<!-- Sidebar -->
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/whatsapp2.js" defer></script>
<script src="/pos/static/js/whatsapp2.js?v=5" defer></script>
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>