${item.name}
${item.part_number} | ${item.brand || ''}
@@ -719,6 +925,290 @@ const POS = (() => {
document.getElementById('itemSearch').focus();
}
+ window.highlightSearchResult = highlightSearchResult;
+ window.POS_searchHover = function (idx) { searchActiveIndex = idx; highlightSearchResult(); };
+
+
+ // ─── Venta offline (cola + replay) ─────────────────────
+ function offlineQueue() {
+ try { return JSON.parse(localStorage.getItem('pos_offline_sales') || '[]'); }
+ catch (e) { return []; }
+ }
+ function offlineSave(queue) {
+ try { localStorage.setItem('pos_offline_sales', JSON.stringify(queue)); } catch (e) {}
+ const el = document.getElementById('offlineQueueBadge');
+ if (el) el.textContent = queue.length;
+ }
+
+ function queueOfflineSale(saleData) {
+ const queue = offlineQueue();
+ saleData.client_uuid = (crypto.randomUUID ? crypto.randomUUID() : 'off-' + Date.now() + '-' + Math.random().toString(36).slice(2));
+ saleData._queued_at = new Date().toISOString();
+ queue.push(saleData);
+ offlineSave(queue);
+ return saleData.client_uuid;
+ }
+
+ async function replayOfflineSales() {
+ const queue = offlineQueue();
+ if (!queue.length) return 0;
+ const remaining = [];
+ let synced = 0;
+ for (const saleData of queue) {
+ try {
+ await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData) });
+ synced++;
+ } catch (e) {
+ remaining.push(saleData); // aún sin conexión: se reintenta luego
+ }
+ }
+ offlineSave(remaining);
+ if (synced) showToast(synced + ' venta(s) offline sincronizada(s)', 'ok');
+ return synced;
+ }
+
+ window.addEventListener('online', function () {
+ if (typeof showOfflineBanner === 'function') showOfflineBanner(false);
+ replayOfflineSales();
+ });
+ window.addEventListener('offline', function () {
+ if (typeof showOfflineBanner === 'function') showOfflineBanner(true);
+ });
+
+ // ─── Devoluciones UI ──────────────────
+ let returnSale = null;
+
+ function openReturnModal() {
+ if (!currentPerms.includes('pos.sell')) { showToast('Sin permiso para devoluciones'); return; }
+ returnSale = null;
+ document.getElementById('returnSaleId').value = '';
+ document.getElementById('returnSaleInfo').style.display = 'none';
+ document.getElementById('returnItemsList').style.display = 'none';
+ document.getElementById('returnItemsList').innerHTML = '';
+ document.getElementById('returnFooter').style.display = 'none';
+ document.getElementById('returnResult').innerHTML = '';
+ document.getElementById('returnModal').classList.add('open');
+ setTimeout(() => document.getElementById('returnSaleId').focus(), 100);
+ }
+ function closeReturnModal() {
+ document.getElementById('returnModal').classList.remove('open');
+ }
+
+ async function searchReturnSale() {
+ const id = parseInt(document.getElementById('returnSaleId').value, 10);
+ if (!id) return;
+ try {
+ const sale = await api('/pos/api/sales/' + id);
+ if (!sale || !sale.items || !sale.items.length) throw new Error('Venta sin partidas');
+ if (sale.status === 'cancelled') throw new Error('La venta está cancelada');
+ returnSale = sale;
+ document.getElementById('returnSaleInfo').style.display = 'block';
+ document.getElementById('returnSaleInfo').innerHTML =
+ `Venta #${sale.id} — ${sale.customer_name || 'Público General'} — Total: ${fmt(sale.total)} — Estado: ${sale.status}`;
+ const list = document.getElementById('returnItemsList');
+ list.style.display = 'block';
+ list.innerHTML = sale.items.map(it => `
+
+
+ ${it.name || it.part_number} (${it.part_number})
+ cant: ${it.quantity}
+
+
+
`).join('');
+ document.getElementById('returnFooter').style.display = 'flex';
+ } catch (e) {
+ document.getElementById('returnResult').innerHTML = '
' + esc(e.message) + '';
+ }
+ }
+
+ async function submitReturn() {
+ if (!returnSale) return;
+ const items = [];
+ returnSale.items.forEach(it => {
+ const chk = document.getElementById('ret-chk-' + it.id);
+ if (chk && chk.checked) {
+ const qty = parseInt(document.getElementById('ret-qty-' + it.id).value, 10) || 0;
+ const reason = (document.getElementById('ret-reason-' + it.id).value || '').trim();
+ if (qty > 0) items.push({ sale_item_id: it.id, quantity: qty, reason: reason || 'Devolución' });
+ }
+ });
+ if (!items.length) {
+ document.getElementById('returnResult').innerHTML = '
Selecciona al menos una partida';
+ return;
+ }
+ try {
+ const res = await api('/pos/api/returns', {
+ method: 'POST',
+ body: JSON.stringify({
+ sale_id: returnSale.id,
+ items,
+ notes: document.getElementById('returnNotes').value,
+ register_id: currentRegister ? currentRegister.id : null,
+ refund_method: returnSale.payment_method || 'efectivo',
+ }),
+ });
+ document.getElementById('returnResult').innerHTML =
+ '
Devolución registrada: reembolso ' + fmt(res.total_refund || 0) + '';
+ renderCart();
+ } catch (e) {
+ document.getElementById('returnResult').innerHTML = '
' + esc(e.message) + '';
+ }
+ }
+
+ // ─── Apartados UI ─────────────────────
+ let currentLayawayId = null;
+
+ function openLayawaysModal() {
+ document.getElementById('layawaysModal').classList.add('open');
+ document.getElementById('layawayActions').style.display = 'none';
+ loadLayaways();
+ }
+ function closeLayawaysModal() {
+ document.getElementById('layawaysModal').classList.remove('open');
+ }
+
+ async function loadLayaways() {
+ const el = document.getElementById('layawaysList');
+ try {
+ const data = await api('/pos/api/layaways?status=active&per_page=50');
+ const rows = data.data || data.layaways || [];
+ if (!rows.length) {
+ el.innerHTML = '
No hay apartados activos';
+ return;
+ }
+ el.innerHTML = rows.map(l => {
+ const balance = (l.total || 0) - (l.amount_paid || 0);
+ return `
+
+
Apartado #${l.id} — ${l.customer_name || 'Sin cliente'}
+
Vence: ${l.expires_at || '—'}
+
+
+
${fmt(balance)}
+
de ${fmt(l.total)}
+
+
`;
+ }).join('');
+ } catch (e) {
+ el.innerHTML = '
Error cargando apartados';
+ }
+ }
+ function selectLayaway(id, total, paid, customerName) {
+ currentLayawayId = id;
+ document.getElementById('layawayActions').style.display = 'block';
+ document.getElementById('layawayDetail').innerHTML =
+ `
Apartado #${id} — ${customerName} — Saldo:
${fmt(total - paid)}`;
+ document.getElementById('layawayPayAmount').value = (Math.round((total - paid) * 100) / 100) || '';
+ document.getElementById('layawayResult').innerHTML = '';
+ }
+
+ async function layawayPay() {
+ if (!currentLayawayId) return;
+ const amount = parseFloat(document.getElementById('layawayPayAmount').value) || 0;
+ if (amount <= 0) return;
+ try {
+ await api(`/pos/api/layaways/${currentLayawayId}/payment`, {
+ method: 'POST',
+ body: JSON.stringify({ amount, payment_method: document.getElementById('layawayPayMethod').value }),
+ });
+ document.getElementById('layawayResult').innerHTML = '
Abono registrado';
+ loadLayaways();
+ } catch (e) {
+ document.getElementById('layawayResult').innerHTML = '
' + esc(e.message) + '';
+ }
+ }
+
+ async function layawayComplete() {
+ if (!currentLayawayId) return;
+ try {
+ const res = await api(`/pos/api/layaways/${currentLayawayId}/complete`, {
+ method: 'POST',
+ body: JSON.stringify({
+ payment_method: document.getElementById('layawayPayMethod').value,
+ register_id: currentRegister ? currentRegister.id : null,
+ }),
+ });
+ document.getElementById('layawayResult').innerHTML = '
Apartado liquidado' + (res.id ? ' — Venta #' + res.id : '') + '';
+ loadLayaways();
+ } catch (e) {
+ document.getElementById('layawayResult').innerHTML = '
' + esc(e.message) + '';
+ }
+ }
+
+ async function layawayCancel() {
+ if (!currentLayawayId) return;
+ if (!confirm('¿Cancelar el apartado? El stock regresará a inventario.')) return;
+ try {
+ await api(`/pos/api/layaways/${currentLayawayId}/cancel`, { method: 'PUT', body: '{}' });
+ document.getElementById('layawayResult').innerHTML = '
Apartado cancelado';
+ loadLayaways();
+ } catch (e) {
+ document.getElementById('layawayResult').innerHTML = '
' + esc(e.message) + '';
+ }
+ }
+
+
+ // ─── Vehículo: placa/VIN → piezas compatibles ─────────────────
+ async function vehicleLookup() {
+ const input = document.getElementById('vehicleLookup');
+ const raw = (input.value || '').trim();
+ if (!raw) return;
+ const compact = raw.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
+ const container = document.getElementById('searchResults');
+ const totals = document.getElementById('totalsPanel');
+ container.innerHTML = '
Buscando vehículo...
';
+ container.style.display = '';
+ totals.style.display = 'none';
+
+ let myeId = null, label = '';
+ try {
+ if (compact.length === 17) {
+ const d = await api('/pos/api/catalog/vin/' + encodeURIComponent(compact));
+ const m = d.match || d.catalog_match || d;
+ myeId = d.mye_id || m.mye_id || m.id_mye || null;
+ label = [m.make || d.make, m.model || d.model, m.year || d.year].filter(Boolean).join(' ');
+ } else {
+ const d = await api('/pos/api/catalog/plate/' + encodeURIComponent(raw));
+ if (!d.found) throw new Error('Placa no registrada — regístrala en el cliente');
+ const m = d.catalog_match || {};
+ myeId = m.mye_id || m.id_mye || null;
+ label = [d.make, d.model, d.year].filter(Boolean).join(' ');
+ if (d.customer_id) {
+ try {
+ const cust = await api('/pos/api/customers/' + d.customer_id);
+ if (cust && cust.data && !currentCustomer) currentCustomer = cust.data;
+ } catch (e) { /* opcional */ }
+ }
+ }
+ if (!myeId) {
+ throw new Error('Vehículo encontrado pero sin motor en catálogo; usa el catálogo por modelo/año');
+ }
+ const data = await api('/pos/api/catalog/vehicle-inventory?mye_id=' + myeId);
+ const items = data.data || [];
+ const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
+ let html = '
🚗 Piezas compatibles: ' + esc(label || 'vehículo') + ' (' + items.length + ')
';
+ if (!items.length) {
+ html += '
Sin piezas compatibles registradas aún
';
+ } else {
+ items.forEach(item => {
+ const price = priceForTier(item.price_1, tier, item);
+ html += `
+
+
${item.name}
+
${item.part_number} | ${item.brand || ''}
+
Stock: ${item.stock}
+
+
${fmt(price)}
+
`;
+ });
+ }
+ container.innerHTML = html;
+ } catch (e) {
+ container.innerHTML = '
' + esc(e.message) + '
';
+ }
+ }
function hideSearchResults() {
const sr = document.getElementById('searchResults');
const tp = document.getElementById('totalsPanel');
@@ -923,6 +1413,8 @@ const POS = (() => {
document.getElementById('changeDisplay').className = 'cambio-amount positive';
const payRef = document.getElementById('paymentRef');
if (payRef) payRef.value = '';
+ const saleCourierSel = document.getElementById('saleCourier');
+ if (saleCourierSel) saleCourierSel.value = '';
// Populate quick amounts
const quickContainer = document.getElementById('quickAmounts');
@@ -998,9 +1490,22 @@ const POS = (() => {
}
}
+ function getPayCurrency() {
+ const sel = document.getElementById('payCurrency');
+ return sel ? sel.value : 'MXN';
+ }
+
+ function getUsdRate() {
+ const r = parseFloat(localStorage.getItem('pos_exchange_rate')) || 17.5;
+ return r > 0 ? r : 17.5;
+ }
+
function updateChange() {
const total = getTotal();
- const received = parseFloat(document.getElementById('cashReceived').value) || 0;
+ let received = parseFloat(document.getElementById('cashReceived').value) || 0;
+ const hint = document.getElementById('usdRateHint');
+ if (hint) hint.textContent = getPayCurrency() === 'USD' ? ('TC: ' + getUsdRate()) : '';
+ if (getPayCurrency() === 'USD') received = received * getUsdRate();
const change = received - total;
const el = document.getElementById('changeDisplay');
if (change >= 0) {
@@ -1035,8 +1540,14 @@ const POS = (() => {
let reference = '';
let saleType = 'cash';
+ let payCurrencyNote = '';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
+ if (getPayCurrency() === 'USD') {
+ const usd = amountPaid;
+ amountPaid = Math.round(usd * getUsdRate() * 100) / 100;
+ payCurrencyNote = `Pago en USD: $${usd.toFixed(2)} @ TC ${getUsdRate()}`;
+ }
if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; }
} else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') {
amountPaid = total;
@@ -1083,8 +1594,10 @@ const POS = (() => {
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
- reference: reference,
+ reference: [reference, payCurrencyNote].filter(Boolean).join(' | '),
generate_cfdi: document.getElementById('cfdiCheck').checked,
+ courier_id: (document.getElementById('saleCourier') || {}).value
+ ? parseInt(document.getElementById('saleCourier').value, 10) : null,
};
const zeroItem = findZeroPriceItem();
@@ -1126,16 +1639,42 @@ const POS = (() => {
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
+ if (saleData.courier_id) {
+ const courier = couriers.find(c => c.id === saleData.courier_id);
+ sale.courier_name = courier ? courier.name : '';
+ }
closePaymentModal();
showTicket(sale);
+ if (rc('auto_print', '') === true || rc('auto_print', '') === 'true') {
+ try { printTicket(); } catch (e) { /* impresión no bloquea */ }
+ }
// Clear cart
cart = [];
selectedRow = -1;
clearCustomer();
+ const saleCourierSel2 = document.getElementById('saleCourier');
+ if (saleCourierSel2) saleCourierSel2.value = '';
renderCart();
} catch (e) {
- alert('Error al procesar venta: ' + e.message);
+ // Sin conexión: encolar la venta para sincronizar después
+ if (navigator.onLine === false || /network|failed to fetch/i.test(e.message)) {
+ const uuid = queueOfflineSale(saleData);
+ closePaymentModal();
+ showTicket({
+ id: 'OFF-' + uuid.slice(0, 8),
+ total: total,
+ items: saleData.items.map(it => ({ name: it.part_number || it.inventory_id, quantity: it.quantity, unit_price: it.unit_price, subtotal: it.unit_price * it.quantity })),
+ customer_name: currentCustomer ? currentCustomer.name : 'Público General',
+ payment_method: paymentMethod,
+ offline: true,
+ created_at: new Date().toISOString(),
+ });
+ cart = []; selectedRow = -1; renderCart();
+ showToast('Sin conexión — venta en cola (' + offlineQueue().length + ' pendientes). Se sincronizará automáticamente.', 'ok');
+ } else {
+ alert('Error al procesar venta: ' + e.message);
+ }
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Confirmar Pago';
@@ -1593,7 +2132,7 @@ const POS = (() => {
Cliente: ${customerName}
${customerRfc ? `
RFC: ${customerRfc}` : ''}
Cant
@@ -1792,7 +2331,15 @@ const POS = (() => {
// ─── Drawer ──────────────────────────
function openDrawer() {
- showToast('Comando enviado al cajon de efectivo.');
+ // Kick ESC p 0 25 250 via connected thermal printer (USB/Serial)
+ try {
+ if (window.NexusPrinter && typeof window.NexusPrinter.sendRaw === 'function') {
+ window.NexusPrinter.sendRaw(new Uint8Array([27, 112, 48, 25, 250]))
+ .then(ok => showToast(ok ? 'Cajón abierto.' : 'Sin impresora conectada — no se pudo abrir el cajón.', ok ? 'ok' : 'error'));
+ return;
+ }
+ } catch (e) { /* fallthrough */ }
+ showToast('Conecta una impresora térmica (Config → Impresora) para abrir el cajón.', 'error');
}
// ─── Keyboard Shortcuts ──────────────
@@ -1924,6 +2471,9 @@ const POS = (() => {
addToCart, removeFromCart, clearCart, selectRow,
updateQty, updateDiscount,
addFromSearch, hideSearchResults,
+ openReturnModal, closeReturnModal, searchReturnSale, submitReturn, vehicleLookup,
+ openLayawaysModal, closeLayawaysModal, loadLayaways, selectLayaway,
+ layawayPay, layawayComplete, layawayCancel,
selectCustomer, clearCustomer,
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
@@ -1938,6 +2488,7 @@ const POS = (() => {
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
+ refreshCrossSell, hideCrossSell, addCrossSellItem,
};
})();
diff --git a/pos/static/js/push.js b/pos/static/js/push.js
index 7caabd2..3a8fdce 100644
--- a/pos/static/js/push.js
+++ b/pos/static/js/push.js
@@ -36,7 +36,7 @@
async function setupPush() {
try {
// Register service worker
- var registration = await navigator.serviceWorker.register('/pos/static/sw-push.js', {
+ var registration = await navigator.serviceWorker.register('/pos/sw-push.js', {
scope: '/pos/'
});
console.log('[Push] Service worker registered');
diff --git a/pos/static/js/sidebar.js b/pos/static/js/sidebar.js
index 4527484..bf5caa5 100644
--- a/pos/static/js/sidebar.js
+++ b/pos/static/js/sidebar.js
@@ -51,6 +51,7 @@ window.renderSidebar = function(modulesOverride) {
'pos.view': 'pos',
'catalog.view': 'catalog',
'inventory.view': 'inventory',
+ 'yonke.view': 'yonke',
'customers.view': 'customers',
'workshop.view': 'workshop',
'pos.remission': 'remission_notes',
@@ -76,6 +77,7 @@ window.renderSidebar = function(modulesOverride) {
{ id: 'pos', name: _t('pos'), href: '/pos/sale', icon: '
' },
moduleEnabled('catalog') ? { id: 'catalog', name: _t('catalog'), href: '/pos/catalog', icon: '
' } : null,
{ id: 'inventory', name: _t('inventory'), href: '/pos/inventory', icon: '
' },
+ { id: 'yonke', name: 'Yonke (usadas)', href: '/pos/yonke', icon: '
' },
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ label: _t('nav_management'), items: [
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '
' },
diff --git a/pos/static/js/yonke.js b/pos/static/js/yonke.js
new file mode 100644
index 0000000..a6b46d6
--- /dev/null
+++ b/pos/static/js/yonke.js
@@ -0,0 +1,3024 @@
+// /home/Autopartes/pos/static/js/yonke.js
+// Nexus Yonke — used parts (partes usadas / deshuesadero) UI.
+//
+// Tabs: piezas (list + search), nueva (fast intake), donador (search by donor
+// vehicle), consignaciones (third-party parts on consignment), b2b (wholesale
+// marketplace between businesses).
+//
+// Backend contract: /pos/api/yonke/*
+// GET /pieces POST /pieces
+// POST /pieces/
/images DELETE /pieces//images/
+// PUT /pieces/
+// GET /vin/
+// GET /donors
+// GET /consignments POST /consignments
+// GET /consignments/ POST /consignments//items
+// PUT /consignments//settle
+//
+// B2B contract: /pos/api/b2b (see the "MAYOREO B2B" section)
+
+(function () {
+ 'use strict';
+
+ var API = '/pos/api/yonke';
+
+ // ─── Auth ────────────────────────────────────────────────────────────
+
+ function getToken() {
+ return localStorage.getItem('pos_token') || localStorage.getItem('access_token') || '';
+ }
+ if (!getToken()) { window.location.href = '/pos/login'; return; }
+
+ function authHeaders() {
+ return { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' };
+ }
+
+ // Reissue the JWT (same endpoint app-init.js uses) so an expired token or a
+ // permission change does not kick the user straight back to the login page.
+ var refreshPromise = null;
+ function refreshToken() {
+ if (!refreshPromise) {
+ var hdrs = { 'Authorization': 'Bearer ' + getToken() };
+ refreshPromise = fetch('/pos/api/auth/refresh', { method: 'POST', headers: hdrs })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (d && d.token) {
+ localStorage.setItem('pos_token', d.token);
+ if (d.employee) {
+ try { localStorage.setItem('pos_employee', JSON.stringify(d.employee)); } catch (e) {}
+ }
+ if (window.POS_USER) {
+ window.POS_USER.token = d.token;
+ if (d.permissions) window.POS_USER.permissions = d.permissions;
+ }
+ return d.token;
+ }
+ return null;
+ })
+ .catch(function () { return null; })
+ .then(function (t) { refreshPromise = null; return t; });
+ }
+ return refreshPromise;
+ }
+
+ function request(url, opts) {
+ opts = opts || {};
+ var init = { method: opts.method || 'GET', headers: Object.assign(authHeaders(), opts.headers || {}) };
+ if (opts.body !== undefined) init.body = opts.body;
+ if (opts.signal) init.signal = opts.signal;
+ return fetch(url, init);
+ }
+
+ function parseResponse(resp) {
+ if (!resp.ok) {
+ return resp.json().catch(function () { return {}; }).then(function (body) {
+ var msg = (body && (body.error || body.message)) || ('Error HTTP ' + resp.status);
+ var err = new Error(msg);
+ err.status = resp.status;
+ err.body = body;
+ throw err;
+ });
+ }
+ if (resp.status === 204) return null;
+ var ct = resp.headers.get('content-type') || '';
+ if (ct.indexOf('json') === -1) return null;
+ return resp.json().catch(function () { return null; });
+ }
+
+ function apiFetch(url, opts) {
+ opts = opts || {};
+ return request(url, opts).then(function (resp) {
+ if (resp.status === 401) {
+ return refreshToken().then(function (token) {
+ if (!token) return redirectToLogin();
+ return request(url, opts).then(function (retry) {
+ if (retry.status === 401) return redirectToLogin();
+ return parseResponse(retry);
+ });
+ });
+ }
+ return parseResponse(resp);
+ });
+ }
+
+ function redirectToLogin() {
+ localStorage.removeItem('pos_token');
+ window.location.href = '/pos/login';
+ return null;
+ }
+
+ // Multipart upload — must NOT carry the JSON Content-Type.
+ function apiUpload(url, formData) {
+ return fetch(url, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + getToken() },
+ body: formData
+ }).then(function (resp) {
+ if (resp.status === 401) {
+ return refreshToken().then(function (token) {
+ if (!token) return redirectToLogin();
+ return fetch(url, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + token },
+ body: formData
+ }).then(parseResponse);
+ });
+ }
+ return parseResponse(resp);
+ });
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────────
+
+ function esc(s) {
+ if (s === null || s === undefined || s === '') return '';
+ var d = document.createElement('div');
+ d.textContent = String(s);
+ return d.innerHTML;
+ }
+ function num(n) { return parseFloat(n) || 0; }
+ function fmtMoney(n) {
+ return '$' + num(n).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ }
+ function fmtInt(n) { return parseInt(n, 10) || 0; }
+ function qs(params) {
+ var sp = new URLSearchParams();
+ Object.keys(params).forEach(function (k) {
+ var v = params[k];
+ if (v === '' || v === null || v === undefined || (typeof v === 'number' && isNaN(v))) return;
+ sp.set(k, v);
+ });
+ return sp.toString();
+ }
+ function asList(res) {
+ if (!res) return [];
+ if (Array.isArray(res)) return res;
+ if (Array.isArray(res.data)) return res.data;
+ if (Array.isArray(res.items)) return res.items;
+ if (res.data && Array.isArray(res.data.items)) return res.data.items;
+ return [];
+ }
+ function unwrap(res) {
+ if (res && res.data && typeof res.data === 'object' && !Array.isArray(res.data)) return res.data;
+ return res || {};
+ }
+ function val(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
+ function numOrNull(id) {
+ var v = val(id);
+ if (v === '') return null;
+ var n = parseFloat(v);
+ return isNaN(n) ? null : n;
+ }
+ function intOrNull(id) {
+ var n = numOrNull(id);
+ return n === null ? null : Math.round(n);
+ }
+ function openModal(id) { var el = document.getElementById(id); if (el) el.classList.add('is-open'); }
+ function toast(msg, type) {
+ if (typeof window.showToast === 'function') window.showToast(msg, type || 'ok');
+ }
+ function errMsg(err) {
+ if (!err) return 'Error desconocido';
+ if (err.status === 403) return 'Sin permiso para esta acción (403)';
+ if (err.status === 404) return 'No encontrado (404)';
+ return err.message || 'Error de red';
+ }
+ function emptyRow(colspan, text) {
+ return '| ' + esc(text) + ' |
';
+ }
+ function primaryImage(item) {
+ var imgs = (item && item.images) || [];
+ for (var i = 0; i < imgs.length; i++) { if (imgs[i].is_primary) return imgs[i].url; }
+ if (imgs.length) return imgs[0].url;
+ return item && item.image_url ? item.image_url : '';
+ }
+ function thumbHtml(item) {
+ var url = primaryImage(item);
+ var inner = url
+ ? '
'
+ : 'sin
foto
';
+ return '' + inner + '
';
+ }
+ // A broken image URL must not leave an unclickable blank cell.
+ window.ynkThumbFallback = function (img) {
+ var slot = img && img.parentNode;
+ if (slot) slot.innerHTML = 'sin
foto
';
+ };
+ function gradeHtml(grade) {
+ if (!grade) return '—';
+ var g = String(grade).toUpperCase().charAt(0);
+ return '' + esc(g) + '';
+ }
+ function conditionBadge(condition) {
+ var c = (condition || '').toLowerCase();
+ var cls = c === 'nuevo' ? 'badge--ok' : (c === 'reconstruido' ? 'badge--transit' : 'badge--pending');
+ return '' + esc(c || '—') + '';
+ }
+ function donorHtml(item) {
+ var veh = [item.donor_make, item.donor_model, item.donor_year].filter(Boolean).join(' ');
+ var vin = item.donor_vin || '';
+ if (!veh && !vin) return '—';
+ return '' + esc(veh || 'Vehículo sin datos') + '
' +
+ (vin ? '' + esc(vin) + '
' : '');
+ }
+ function renderPagination(el, pg, fnName) {
+ if (!el) return;
+ pg = pg || {};
+ if (!pg.total_pages || pg.total_pages <= 1) {
+ el.innerHTML = '';
+ return;
+ }
+ el.innerHTML =
+ '' +
+ '' +
+ pg.page + ' / ' + pg.total_pages + '' +
+ '';
+ }
+
+ // Permissions — the server enforces them; this only hides buttons.
+ var user = window.POS_USER || {};
+ var role = (user.role || '').toLowerCase();
+ var perms = user.permissions || [];
+ function hasPerm(p) { return role === 'owner' || role === 'admin' || perms.indexOf(p) !== -1; }
+ function canEdit() { return role === 'owner' || role === 'admin' || hasPerm('yonke.manage') || hasPerm('yonke.edit') || hasPerm('inventory.edit'); }
+
+ // ─── Tab switching ───────────────────────────────────────────────────
+
+ var TABS = {
+ piezas: 'panel-piezas',
+ nueva: 'panel-nueva',
+ donador: 'panel-donador',
+ consignaciones: 'panel-consignaciones',
+ b2b: 'panel-b2b',
+ reportes: 'panel-reportes'
+ };
+ var activeTab = 'piezas';
+
+ function switchTab(name) {
+ if (!TABS[name]) return;
+ activeTab = name;
+
+ document.querySelectorAll('.tab-btn').forEach(function (btn) {
+ var on = btn.getAttribute('aria-controls') === TABS[name];
+ btn.classList.toggle('is-active', on);
+ btn.setAttribute('aria-selected', on ? 'true' : 'false');
+ });
+ document.querySelectorAll('.tab-panel').forEach(function (p) {
+ p.classList.toggle('is-active', p.id === TABS[name]);
+ });
+
+ var panels = document.getElementById('tab-panels');
+ if (panels) panels.scrollTop = 0;
+ if (window.location.hash !== '#' + name) {
+ try { history.replaceState(null, '', '#' + name); } catch (e) {}
+ }
+
+ if (name === 'piezas') loadPieces(1);
+ if (name === 'nueva') {
+ loadConsignmentOptions();
+ fillConsignmentSelect('ynkFullConsignmentId');
+ if (intakeMode === 'full') {
+ var fullRows = document.getElementById('ynkFullPiecesBody');
+ if (fullRows && !fullRows.children.length) {
+ for (var fr = 0; fr < 3; fr++) ynkFullAddRow();
+ }
+ }
+ startIntakeTimer();
+ } else {
+ stopIntakeTimer();
+ }
+ if (name === 'donador') ynkSetDonorView(donorView);
+ if (name === 'consignaciones') loadConsignments();
+ if (name === 'b2b') ynkB2bInit();
+ if (name === 'reportes') ynkSetReportView(reportView);
+ }
+
+ // ─── Summary cards ───────────────────────────────────────────────────
+
+ function setStat(id, value) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = value === null || value === undefined ? '--' : value;
+ }
+
+ function refreshSummary() {
+ apiFetch(API + '/pieces?per_page=1')
+ .then(function (res) {
+ if (!res) return;
+ var total = res.pagination ? res.pagination.total : asList(res).length;
+ setStat('ynkStatPieces', fmtInt(total));
+ var b = document.getElementById('badge-piezas');
+ if (b) b.textContent = fmtInt(total);
+ })
+ .catch(function () { setStat('ynkStatPieces', '--'); });
+
+ apiFetch(API + '/pieces?ownership=consigna&per_page=1')
+ .then(function (res) {
+ if (!res) return;
+ var total = res.pagination ? res.pagination.total : asList(res).length;
+ setStat('ynkStatConsignPieces', fmtInt(total));
+ })
+ .catch(function () { setStat('ynkStatConsignPieces', '--'); });
+
+ apiFetch(API + '/donors?page=1')
+ .then(function (res) {
+ if (!res) return;
+ var total = res.pagination ? res.pagination.total : asList(res).length;
+ setStat('ynkStatDonors', fmtInt(total));
+ var b = document.getElementById('badge-donadores');
+ if (b) b.textContent = fmtInt(total);
+ })
+ .catch(function () { setStat('ynkStatDonors', '--'); });
+ }
+
+ // =====================================================================
+ // TAB 1 — PIEZAS USADAS
+ // =====================================================================
+
+ var pieceById = {};
+ var piecesAbort = null;
+
+ function loadPieces(page) {
+ var body = document.getElementById('ynkPiecesBody');
+ if (!body) return;
+ body.innerHTML = emptyRow(12, 'Cargando piezas…');
+
+ var params = {
+ q: val('ynkQ'),
+ condition: document.getElementById('ynkFiltCondition').value,
+ grade: document.getElementById('ynkFiltGrade').value,
+ ownership: document.getElementById('ynkFiltOwnership').value,
+ page: page || 1,
+ per_page: document.getElementById('ynkFiltPerPage').value || 50
+ };
+
+ if (piecesAbort) piecesAbort.abort();
+ piecesAbort = new AbortController();
+
+ apiFetch(API + '/pieces?' + qs(params), { signal: piecesAbort.signal })
+ .then(function (res) {
+ piecesAbort = null;
+ if (!res) return;
+ var items = asList(res);
+ if (!items.length) {
+ body.innerHTML = emptyRow(12, params.q || params.condition || params.grade || params.ownership
+ ? 'Sin piezas que coincidan con los filtros.'
+ : 'Aún no hay piezas usadas. Usa "Nueva pieza" para dar de alta la primera.');
+ document.getElementById('ynkPiecesPagination').innerHTML = '';
+ document.getElementById('ynkPiecesFooter').textContent = '0 piezas';
+ return;
+ }
+ pieceById = {};
+ body.innerHTML = items.map(pieceRow).join('');
+
+ var pg = res.pagination || {};
+ document.getElementById('ynkPiecesFooter').textContent =
+ fmtInt(pg.total !== undefined ? pg.total : items.length) + ' piezas';
+ renderPagination(document.getElementById('ynkPiecesPagination'), pg, 'ynkPiecesPage');
+ })
+ .catch(function (err) {
+ if (!err || err.name === 'AbortError') return;
+ piecesAbort = null;
+ body.innerHTML = emptyRow(12, 'Error al cargar piezas: ' + errMsg(err));
+ toast('Error al cargar piezas: ' + errMsg(err), 'error');
+ });
+ }
+
+ function pieceRow(it) {
+ pieceById[it.id] = it;
+ return '' +
+ '| ' + thumbHtml(it) + ' | ' +
+ '' + (esc(it.part_number) || '—') +
+ (it.is_unique_piece ? '1 de 1' : '') + ' | ' +
+ '' + esc(it.name) + ' | ' +
+ '' + esc(it.brand) + ' | ' +
+ '' + conditionBadge(it.condition) +
+ (it.ownership === 'consigna' ? ' consigna' : '') + ' | ' +
+ '' + gradeHtml(it.condition_grade) + ' | ' +
+ '' + donorHtml(it) + ' | ' +
+ '' + fmtMoney(it.price_1) + ' | ' +
+ '' + fmtMoney(it.cost) + ' | ' +
+ '' + fmtInt(it.stock) + ' | ' +
+ '' + esc(it.location) + ' | ' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ (canEdit() ? '' : '') +
+ ' |
';
+ }
+
+ // =====================================================================
+ // TAB 2 — NUEVA PIEZA (alta rápida)
+ // =====================================================================
+
+ var pendingPhotos = []; // [{file, url}]
+ var intakeTimer = null;
+ var intakeStart = 0;
+
+ function startIntakeTimer() {
+ if (intakeTimer) return;
+ intakeStart = Date.now();
+ var el = document.getElementById('ynkTimer');
+ if (el) el.textContent = '⏱ 0s';
+ intakeTimer = setInterval(function () {
+ var el2 = document.getElementById('ynkTimer');
+ if (el2) el2.textContent = '⏱ ' + Math.round((Date.now() - intakeStart) / 1000) + 's';
+ }, 1000);
+ }
+ function stopIntakeTimer() {
+ if (intakeTimer) { clearInterval(intakeTimer); intakeTimer = null; }
+ }
+ function intakeElapsed() { return Math.round((Date.now() - intakeStart) / 1000); }
+
+ // The API only accepts these formats (see yonke_bp.ALLOWED_EXTENSIONS).
+ var ALLOWED_IMAGE = /^image\/(jpeg|jpg|png|webp)$/i;
+ function isAllowedImage(f) {
+ return ALLOWED_IMAGE.test(f.type || '') || /\.(jpe?g|png|webp)$/i.test(f.name || '');
+ }
+
+ function addPhotos(fileList) {
+ var skipped = 0;
+ Array.prototype.slice.call(fileList || []).forEach(function (f) {
+ if (!isAllowedImage(f)) { skipped++; return; }
+ pendingPhotos.push({ file: f, url: URL.createObjectURL(f) });
+ });
+ renderPhotoPreview();
+ if (skipped) showToast(skipped + ' archivo(s) omitidos: solo JPG, PNG o WEBP.', 'warn');
+ }
+
+ function renderPhotoPreview() {
+ var grid = document.getElementById('ynkPhotoPreview');
+ var counter = document.getElementById('ynkPhotoCount');
+ if (counter) {
+ counter.textContent = pendingPhotos.length
+ ? pendingPhotos.length + ' foto(s) listas para subir'
+ : 'Sin fotos seleccionadas';
+ }
+ if (!grid) return;
+ grid.innerHTML = pendingPhotos.map(function (p, i) {
+ return '
' +
+ '
';
+ }).join('');
+ }
+
+ function ynkRemovePhoto(i) {
+ var p = pendingPhotos[i];
+ if (!p) return;
+ try { URL.revokeObjectURL(p.url); } catch (e) {}
+ pendingPhotos.splice(i, 1);
+ renderPhotoPreview();
+ }
+
+ function ynkDecodeVin() {
+ var vin = val('ynkVin').toUpperCase();
+ var status = document.getElementById('ynkVinStatus');
+ if (!vin) {
+ if (status) { status.textContent = 'Escribe un VIN primero.'; status.className = 'ynk-hint ynk-warn'; }
+ return;
+ }
+ var btn = document.getElementById('ynkDecodeBtn');
+ if (btn) btn.disabled = true;
+ if (status) { status.textContent = 'Decodificando ' + vin + '…'; status.className = 'ynk-hint'; }
+
+ apiFetch(API + '/vin/' + encodeURIComponent(vin))
+ .then(function (d) {
+ if (!d) return;
+ var filled = [];
+ if (d.make) { document.getElementById('ynkDonorMake').value = d.make; filled.push(d.make); }
+ if (d.model) { document.getElementById('ynkDonorModel').value = d.model; filled.push(d.model); }
+ if (d.year) { document.getElementById('ynkDonorYear').value = d.year; filled.push(d.year); }
+
+ if (d.error && !filled.length) {
+ if (status) { status.textContent = 'No se pudo decodificar: ' + d.error; status.className = 'ynk-hint ynk-err'; }
+ return;
+ }
+ var detail = [d.engine, d.body_type, d.fuel_type, d.drive_type, d.trim].filter(Boolean).join(' · ');
+ if (status) {
+ status.textContent = (filled.length ? 'Autocompletado: ' + filled.join(' ') : 'VIN sin datos de vehículo.') +
+ (detail ? ' — ' + detail : '') + (d.error ? ' (' + d.error + ')' : '');
+ status.className = 'ynk-hint' + (filled.length ? ' ynk-ok' : ' ynk-warn');
+ }
+ })
+ .catch(function (err) {
+ if (status) { status.textContent = 'No se pudo decodificar: ' + errMsg(err); status.className = 'ynk-hint ynk-err'; }
+ })
+ .then(function () { if (btn) btn.disabled = false; });
+ }
+
+ function buildPiecePayload() {
+ var payload = {
+ name: val('ynkName'),
+ price_1: numOrNull('ynkPrice'),
+ condition: document.getElementById('ynkCondition').value || 'usado',
+ is_unique_piece: !!document.getElementById('ynkUnique').checked
+ };
+ var partNumber = val('ynkPartNumber'); if (partNumber) payload.part_number = partNumber;
+ var brand = val('ynkBrand'); if (brand) payload.brand = brand;
+
+ var cost = numOrNull('ynkCost'); if (cost !== null) payload.cost = cost;
+ var grade = document.getElementById('ynkGrade').value; if (grade) payload.condition_grade = grade;
+
+ var vin = val('ynkVin'); if (vin) payload.donor_vin = vin.toUpperCase();
+ var make = val('ynkDonorMake'); if (make) payload.donor_make = make;
+ var model = val('ynkDonorModel'); if (model) payload.donor_model = model;
+ var year = intOrNull('ynkDonorYear'); if (year !== null) payload.donor_year = year;
+ var km = intOrNull('ynkDonorKm'); if (km !== null) payload.donor_mileage_km = km;
+
+ payload.ownership = document.getElementById('ynkOwnership').value || 'propio';
+ if (payload.ownership === 'consigna') {
+ var cid = document.getElementById('ynkConsignmentId').value;
+ if (cid) payload.consignment_id = parseInt(cid, 10);
+ }
+
+ var location = val('ynkLocation'); if (location) payload.location = location;
+ var warranty = intOrNull('ynkWarranty'); if (warranty !== null) payload.warranty_days = warranty;
+ var stock = intOrNull('ynkInitialStock'); if (stock !== null) payload.initial_stock = stock;
+ var serial = val('ynkSerial'); if (serial) payload.serial_number = serial;
+
+ return payload;
+ }
+
+ function uploadImages(pieceId) {
+ if (!pendingPhotos.length) return Promise.resolve(null);
+ var fd = new FormData();
+ pendingPhotos.forEach(function (p) { fd.append('file', p.file, p.file.name); });
+ return apiUpload(API + '/pieces/' + pieceId + '/images', fd);
+ }
+
+ function savedPieceHtml(id, sku, tail) {
+ return '✔ Pieza #' + id + ' creada — SKU: ' + esc(sku) + '' +
+ (tail ? ' ' + tail : '');
+ }
+
+ function ynkSubmitPiece() {
+ var result = document.getElementById('ynkSaveResult');
+ var btn = document.getElementById('ynkSaveBtn');
+ var payload = buildPiecePayload();
+
+ if (!payload.name) {
+ if (result) { result.className = 'ynk-save-result ynk-err'; result.textContent = 'El nombre de la pieza es obligatorio.'; }
+ document.getElementById('ynkName').focus();
+ return;
+ }
+ if (payload.price_1 === null) {
+ if (result) { result.className = 'ynk-save-result ynk-err'; result.textContent = 'El precio de venta es obligatorio.'; }
+ document.getElementById('ynkPrice').focus();
+ return;
+ }
+
+ var photoCount = pendingPhotos.length;
+ var elapsed = intakeElapsed();
+ stopIntakeTimer();
+ if (btn) { btn.disabled = true; btn.textContent = 'Guardando…'; }
+ if (result) { result.className = 'ynk-save-result'; result.textContent = 'Creando pieza…'; }
+
+ apiFetch(API + '/pieces', { method: 'POST', body: JSON.stringify(payload) })
+ .then(function (item) {
+ if (!item) return;
+ var sku = item.part_number || '(sin SKU)';
+ if (result) {
+ result.className = 'ynk-save-result ynk-ok';
+ result.innerHTML = savedPieceHtml(item.id, sku,
+ (photoCount ? '· subiendo ' + photoCount + ' foto(s)… ' : '') +
+ '(en ' + elapsed + 's)');
+ }
+ toast('Pieza creada — SKU ' + sku, 'ok');
+
+ var after = photoCount
+ ? uploadImages(item.id)
+ .then(function (resp) {
+ var n = resp && resp.images ? resp.images.length : photoCount;
+ if (result) {
+ result.innerHTML = savedPieceHtml(item.id, sku,
+ '(' + n + ' foto(s) subidas, ' + elapsed + 's)');
+ }
+ toast(n + ' foto(s) subidas', 'ok');
+ })
+ .catch(function (err) {
+ if (result) {
+ result.innerHTML = savedPieceHtml(item.id, sku,
+ '— no se pudieron subir las fotos: ' + esc(errMsg(err)) + '');
+ }
+ toast('La pieza se creó, pero fallaron las fotos: ' + errMsg(err), 'warn');
+ })
+ : Promise.resolve();
+
+ return after.then(function () { resetPieceForm(); refreshSummary(); });
+ })
+ .catch(function (err) {
+ if (result) { result.className = 'ynk-save-result ynk-err'; result.textContent = 'No se pudo crear la pieza: ' + errMsg(err); }
+ toast('Error al crear la pieza: ' + errMsg(err), 'error');
+ })
+ .then(function () {
+ if (btn) { btn.disabled = false; btn.textContent = 'Guardar pieza'; }
+ });
+ }
+
+ function resetPieceForm() {
+ ['ynkName', 'ynkPartNumber', 'ynkBrand', 'ynkPrice', 'ynkCost', 'ynkVin', 'ynkDonorMake',
+ 'ynkDonorModel', 'ynkDonorYear', 'ynkDonorKm', 'ynkLocation', 'ynkWarranty', 'ynkSerial']
+ .forEach(function (id) { var el = document.getElementById(id); if (el) el.value = ''; });
+ document.getElementById('ynkCondition').value = 'usado';
+ document.getElementById('ynkGrade').value = '';
+ document.getElementById('ynkUnique').checked = true;
+ document.getElementById('ynkOwnership').value = 'propio';
+ document.getElementById('ynkInitialStock').value = '1';
+ document.getElementById('ynkConsignmentWrap').style.display = 'none';
+ var status = document.getElementById('ynkVinStatus');
+ if (status) { status.textContent = 'Decodifica el VIN para autocompletar marca, modelo y año.'; status.className = 'ynk-hint'; }
+ pendingPhotos.forEach(function (p) { try { URL.revokeObjectURL(p.url); } catch (e) {} });
+ pendingPhotos = [];
+ renderPhotoPreview();
+ var timerEl = document.getElementById('ynkTimer');
+ if (timerEl) timerEl.textContent = '—';
+ var nameEl = document.getElementById('ynkName');
+ if (nameEl) nameEl.focus();
+ }
+
+ // =====================================================================
+ // TAB 3 — POR DONADOR
+ // =====================================================================
+
+ var donorView = 'donors';
+ var donors = [];
+ var donorsAbort = null;
+ var donorPiecesAbort = null;
+
+ function ynkSetDonorView(view) {
+ donorView = view === 'pieces' ? 'pieces' : 'donors';
+ var segD = document.getElementById('ynkSegDonors');
+ var segP = document.getElementById('ynkSegPieces');
+ if (segD) segD.classList.toggle('is-active', donorView === 'donors');
+ if (segP) segP.classList.toggle('is-active', donorView === 'pieces');
+ var vd = document.getElementById('ynkDonorViewDonors');
+ var vp = document.getElementById('ynkDonorViewPieces');
+ if (vd) vd.style.display = donorView === 'donors' ? '' : 'none';
+ if (vp) vp.style.display = donorView === 'pieces' ? '' : 'none';
+ if (donorView === 'donors') loadDonors(1);
+ else { loadDonorPieces(1); refreshSummary(); }
+ }
+
+ function loadDonors(page) {
+ var body = document.getElementById('ynkDonorsBody');
+ if (!body) return;
+ body.innerHTML = emptyRow(7, 'Cargando donadores…');
+
+ if (donorsAbort) donorsAbort.abort();
+ donorsAbort = new AbortController();
+ var params = { q: val('ynkDonorQ'), page: page || 1 };
+
+ apiFetch(API + '/donors?' + qs(params), { signal: donorsAbort.signal })
+ .then(function (res) {
+ donorsAbort = null;
+ if (!res) return;
+ donors = asList(res);
+ if (!donors.length) {
+ body.innerHTML = emptyRow(7, 'Sin donadores registrados. Da de alta una pieza con VIN del donador para verla aquí.');
+ document.getElementById('ynkDonorsFooter').textContent = '0 donadores';
+ document.getElementById('ynkDonorsPagination').innerHTML = '';
+ return;
+ }
+ body.innerHTML = donors.map(function (d, i) {
+ return '' +
+ '| ' + (esc(d.donor_vin) || '—') + ' | ' +
+ '' + (esc(d.donor_make) || '—') + ' | ' +
+ '' + (esc(d.donor_model) || '—') + ' | ' +
+ '' + (esc(d.donor_year) || '—') + ' | ' +
+ '' + fmtInt(d.pieces) + ' | ' +
+ '' + fmtMoney(d.total_value) + ' | ' +
+ ' | ' +
+ '
';
+ }).join('');
+
+ var pg = res.pagination || {};
+ document.getElementById('ynkDonorsFooter').textContent =
+ fmtInt(pg.total !== undefined ? pg.total : donors.length) + ' donadores';
+ renderPagination(document.getElementById('ynkDonorsPagination'), pg, 'ynkDonorsPage');
+ })
+ .catch(function (err) {
+ if (!err || err.name === 'AbortError') return;
+ donorsAbort = null;
+ body.innerHTML = emptyRow(7, 'Error al cargar donadores: ' + errMsg(err));
+ toast('Error al cargar donadores: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkDonorToPieces(i) {
+ var d = donors[i];
+ if (!d) return;
+ document.getElementById('ynkDonorMake').value = d.donor_make || '';
+ document.getElementById('ynkDonorModel').value = d.donor_model || '';
+ document.getElementById('ynkDonorYear').value = d.donor_year || '';
+ document.getElementById('ynkDonorVin').value = d.donor_vin || '';
+ document.getElementById('ynkDonorPieceQ').value = '';
+ ynkSetDonorView('pieces');
+ }
+
+ function loadDonorPieces(page) {
+ var body = document.getElementById('ynkDonorPiecesBody');
+ if (!body) return;
+ body.innerHTML = emptyRow(10, 'Buscando piezas del donador…');
+
+ if (donorPiecesAbort) donorPiecesAbort.abort();
+ donorPiecesAbort = new AbortController();
+ var params = {
+ donor_make: val('ynkDonorMake'),
+ donor_model: val('ynkDonorModel'),
+ donor_year: val('ynkDonorYear'),
+ vin: val('ynkDonorVin'),
+ q: val('ynkDonorPieceQ'),
+ page: page || 1,
+ per_page: 50
+ };
+
+ apiFetch(API + '/pieces?' + qs(params), { signal: donorPiecesAbort.signal })
+ .then(function (res) {
+ donorPiecesAbort = null;
+ if (!res) return;
+ var items = asList(res);
+ if (!items.length) {
+ body.innerHTML = emptyRow(10, 'No hay piezas de ese donador con los filtros actuales.');
+ document.getElementById('ynkDonorPiecesFooter').textContent = '0 piezas';
+ document.getElementById('ynkDonorPiecesPagination').innerHTML = '';
+ return;
+ }
+ body.innerHTML = items.map(function (it) {
+ pieceById[it.id] = it;
+ return '' +
+ '| ' + thumbHtml(it) + ' | ' +
+ '' + (esc(it.part_number) || '—') + ' | ' +
+ '' + esc(it.name) + ' | ' +
+ '' + gradeHtml(it.condition_grade) + ' | ' +
+ '' + conditionBadge(it.condition) + ' | ' +
+ '' + donorHtml(it) + ' | ' +
+ '' + fmtMoney(it.price_1) + ' | ' +
+ '' + fmtInt(it.stock) + ' | ' +
+ '' + esc(it.location) + ' | ' +
+ ' | ' +
+ '
';
+ }).join('');
+
+ var pg = res.pagination || {};
+ document.getElementById('ynkDonorPiecesFooter').textContent =
+ fmtInt(pg.total !== undefined ? pg.total : items.length) + ' piezas';
+ renderPagination(document.getElementById('ynkDonorPiecesPagination'), pg, 'ynkDonorPiecesPage');
+ })
+ .catch(function (err) {
+ if (!err || err.name === 'AbortError') return;
+ donorPiecesAbort = null;
+ body.innerHTML = emptyRow(10, 'Error al buscar piezas: ' + errMsg(err));
+ toast('Error al buscar piezas del donador: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkOpenPieceFromSearch(id) {
+ var it = pieceById[id];
+ if (!it) {
+ toast('Recarga la pestaña "Piezas usadas" para editar esta pieza.', 'warn');
+ return;
+ }
+ openEditPiece(it);
+ }
+
+ // =====================================================================
+ // TAB 4 — CONSIGNACIONES
+ // =====================================================================
+
+ var consignments = [];
+ var currentConsignmentId = null;
+
+ function loadConsignments() {
+ var body = document.getElementById('ynkConsignBody');
+ if (!body) return;
+ body.innerHTML = emptyRow(11, 'Cargando consignaciones…');
+
+ apiFetch(API + '/consignments')
+ .then(function (res) {
+ if (!res) return;
+ consignments = asList(res);
+ renderConsignments();
+ loadConsignmentOptions();
+ })
+ .catch(function (err) {
+ body.innerHTML = emptyRow(11, 'Error al cargar consignaciones: ' + errMsg(err));
+ toast('Error al cargar consignaciones: ' + errMsg(err), 'error');
+ });
+ }
+
+ function renderConsignments() {
+ var body = document.getElementById('ynkConsignBody');
+ if (!body) return;
+ var q = val('ynkConsignQ').toLowerCase();
+ var rows = consignments.filter(function (c) {
+ if (!q) return true;
+ return [(c.consignor_name || ''), (c.consignor_rfc || ''), (c.consignor_phone || '')]
+ .join(' ').toLowerCase().indexOf(q) !== -1;
+ });
+
+ var open = consignments.filter(function (c) { return (c.status || 'open') === 'open'; }).length;
+ setStat('ynkStatConsignments', open + ' / ' + consignments.length);
+ var sub = document.getElementById('ynkStatConsignmentsSub');
+ if (sub) sub.textContent = 'Abiertas / total';
+ var badge = document.getElementById('badge-consignaciones');
+ if (badge) badge.textContent = open;
+
+ if (!rows.length) {
+ body.innerHTML = emptyRow(11, consignments.length ? 'Ninguna consigna coincide con el filtro.' : 'Sin consignaciones registradas todavía.');
+ document.getElementById('ynkConsignFooter').textContent = '0 consignaciones';
+ return;
+ }
+
+ body.innerHTML = rows.map(function (c) {
+ var status = (c.status || 'open').toLowerCase();
+ var statusCls = status === 'open' ? 'badge--pending' : (status === 'settled' ? 'badge--ok' : 'badge--complete');
+ var pieces = c.pieces !== undefined ? c.pieces
+ : (c.items_count !== undefined ? c.items_count : ((c.items || []).length || 0));
+ return '' +
+ '| ' + esc(c.id) + ' | ' +
+ '' + esc(c.consignor_name) + ' | ' +
+ '' + esc(c.consignor_rfc) + ' | ' +
+ '' + esc(c.consignor_phone) + ' | ' +
+ '' + (c.commission_pct !== undefined && c.commission_pct !== null ? esc(c.commission_pct) + '%' : '—') + ' | ' +
+ '' + esc(pieces) + ' | ' +
+ '' + fmtMoney(c.total_sold) + ' | ' +
+ '' + fmtMoney(c.total_commission) + ' | ' +
+ '' + esc(status) + ' | ' +
+ '' + esc((c.created_at || '').substring(0, 10) || '—') + ' | ' +
+ '' +
+ ' ' +
+ '' +
+ ' |
';
+ }).join('');
+
+ document.getElementById('ynkConsignFooter').textContent = rows.length + ' consignaciones';
+ }
+
+ function loadConsignmentOptions() {
+ fillConsignmentSelect('ynkConsignmentId');
+ }
+
+ function fillConsignmentSelect(selId) {
+ var sel = document.getElementById(selId);
+ if (!sel) return;
+ apiFetch(API + '/consignments')
+ .then(function (res) {
+ if (!res) return;
+ var all = asList(res);
+ if (!consignments.length) consignments = all;
+ var open = all.filter(function (c) { return (c.status || 'open') === 'open'; });
+ var current = sel.value;
+ sel.innerHTML = '' + open.map(function (c) {
+ return '';
+ }).join('');
+ if (current) sel.value = current;
+ if (!open.length) {
+ sel.innerHTML = '';
+ }
+ })
+ .catch(function () { /* silent — the select is optional */ });
+ }
+
+ function ynkOpenConsignmentModal() {
+ ['ynkConsName', 'ynkConsRfc', 'ynkConsPhone', 'ynkConsCommission', 'ynkConsNotes']
+ .forEach(function (id) { var el = document.getElementById(id); if (el) el.value = ''; });
+ var res = document.getElementById('ynkConsResult');
+ if (res) { res.textContent = ''; res.className = 'ynk-save-result'; }
+ openModal('ynkConsignModal');
+ setTimeout(function () { document.getElementById('ynkConsName').focus(); }, 50);
+ }
+
+ function ynkCreateConsignment() {
+ var name = val('ynkConsName');
+ var res = document.getElementById('ynkConsResult');
+ var btn = document.getElementById('ynkConsCreateBtn');
+ if (!name) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'El nombre del consignante es obligatorio.'; }
+ return;
+ }
+ var payload = { consignor_name: name };
+ var rfc = val('ynkConsRfc'); if (rfc) payload.consignor_rfc = rfc;
+ var phone = val('ynkConsPhone'); if (phone) payload.consignor_phone = phone;
+ var pct = numOrNull('ynkConsCommission'); if (pct !== null) payload.commission_pct = pct;
+ var notes = val('ynkConsNotes'); if (notes) payload.notes = notes;
+
+ if (btn) btn.disabled = true;
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Creando…'; }
+
+ apiFetch(API + '/consignments', { method: 'POST', body: JSON.stringify(payload) })
+ .then(function (c) {
+ if (!c) return;
+ var created = unwrap(c);
+ toast('Consignación creada: ' + (created.consignor_name || name), 'ok');
+ ynkCloseModal('ynkConsignModal');
+ loadConsignments();
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo crear: ' + errMsg(err); }
+ toast('Error al crear consignación: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) btn.disabled = false; });
+ }
+
+ function ynkOpenConsignment(id) {
+ currentConsignmentId = id;
+ document.getElementById('ynkConsignDetailTitle').textContent = 'Consignación #' + id;
+ document.getElementById('ynkConsignDetailInfo').textContent = 'Cargando…';
+ document.getElementById('ynkConsignItemsBody').innerHTML = emptyRow(6, 'Cargando piezas…');
+ document.getElementById('ynkSettleResult').innerHTML = '';
+ ['ynkItemInvId', 'ynkItemAgreed', 'ynkItemCommission'].forEach(function (i) {
+ var el = document.getElementById(i); if (el) el.value = '';
+ });
+ var r = document.getElementById('ynkConsItemResult');
+ if (r) { r.textContent = ''; r.className = 'ynk-save-result'; }
+ openModal('ynkConsignDetailModal');
+ loadConsignmentDetail(id);
+ }
+
+ function loadConsignmentDetail(id) {
+ return apiFetch(API + '/consignments/' + id)
+ .then(function (res) {
+ if (!res) return null;
+ var c = unwrap(res);
+ var info = document.getElementById('ynkConsignDetailInfo');
+ var items = Array.isArray(c.items) ? c.items : [];
+
+ if (info) {
+ info.innerHTML = '' + esc(c.consignor_name || '—') + '' +
+ (c.consignor_rfc ? ' · RFC ' + esc(c.consignor_rfc) : '') +
+ (c.consignor_phone ? ' · Tel ' + esc(c.consignor_phone) : '') +
+ (c.commission_pct !== undefined && c.commission_pct !== null ? ' · Comisión ' + esc(c.commission_pct) + '%' : '') +
+ ' · Estado: ' + esc(c.status || 'open') +
+ (c.notes ? '
' + esc(c.notes) + '' : '');
+ }
+
+ var body = document.getElementById('ynkConsignItemsBody');
+ if (!items.length) {
+ body.innerHTML = emptyRow(6, 'Esta consigna todavía no tiene piezas.');
+ } else {
+ body.innerHTML = items.map(function (it) {
+ var status = (it.status || '').toLowerCase();
+ var cls = status === 'sold' ? 'badge--ok' : (status === 'settled' ? 'badge--complete' : 'badge--pending');
+ return '' +
+ '| ' + esc(it.inventory_id) + ' | ' +
+ '' + esc(it.part_number) + ' | ' +
+ '' + esc(it.name) + ' | ' +
+ '' + fmtMoney(it.agreed_price) + ' | ' +
+ '' + fmtMoney(it.commission_amount) + ' | ' +
+ '' + esc(status || '—') + ' | ' +
+ '
';
+ }).join('');
+ }
+ return c;
+ })
+ .catch(function (err) {
+ document.getElementById('ynkConsignItemsBody').innerHTML = emptyRow(6, 'Error al cargar el detalle: ' + errMsg(err));
+ toast('Error al cargar la consignación: ' + errMsg(err), 'error');
+ return null;
+ });
+ }
+
+ function ynkAddConsignmentItem() {
+ if (!currentConsignmentId) return;
+ var invId = intOrNull('ynkItemInvId');
+ var agreed = numOrNull('ynkItemAgreed');
+ var res = document.getElementById('ynkConsItemResult');
+
+ if (!invId) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'El ID de inventario es obligatorio.'; }
+ return;
+ }
+ if (agreed === null) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'El precio acordado es obligatorio.'; }
+ return;
+ }
+ var payload = { inventory_id: invId, agreed_price: agreed };
+ var commission = numOrNull('ynkItemCommission');
+ if (commission !== null) payload.commission_amount = commission;
+
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Agregando pieza…'; }
+
+ apiFetch(API + '/consignments/' + currentConsignmentId + '/items', { method: 'POST', body: JSON.stringify(payload) })
+ .then(function () {
+ if (res) { res.className = 'ynk-save-result ynk-ok'; res.textContent = 'Pieza agregada a la consigna.'; }
+ toast('Pieza agregada a la consigna', 'ok');
+ ['ynkItemInvId', 'ynkItemAgreed', 'ynkItemCommission'].forEach(function (i) {
+ var el = document.getElementById(i); if (el) el.value = '';
+ });
+ loadConsignmentDetail(currentConsignmentId);
+ loadConsignments();
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo agregar: ' + errMsg(err); }
+ toast('Error al agregar la pieza: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkSettleConsignment() {
+ if (!currentConsignmentId) return;
+ if (!window.confirm('¿Liquidar la consignación #' + currentConsignmentId + '? Esta acción marca la consigna como liquidada.')) return;
+
+ var btn = document.getElementById('ynkSettleBtn');
+ var box = document.getElementById('ynkSettleResult');
+ if (btn) btn.disabled = true;
+ if (box) box.innerHTML = 'Liquidando…
';
+
+ apiFetch(API + '/consignments/' + currentConsignmentId + '/settle', { method: 'PUT', body: JSON.stringify({}) })
+ .then(function (summary) {
+ if (!summary) return;
+ var s = unwrap(summary);
+ if (box) box.innerHTML = renderSettlementSummary(s);
+ toast('Consignación liquidada', 'ok');
+ loadConsignmentDetail(currentConsignmentId);
+ loadConsignments();
+ })
+ .catch(function (err) {
+ if (box) box.innerHTML = 'No se pudo liquidar: ' + esc(errMsg(err)) + '
';
+ toast('Error al liquidar: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) btn.disabled = false; });
+ }
+
+ // The settle response shape is not fixed by the contract, so render any
+ // scalar key it returns with a friendly label, and any items[] as a table.
+ var SUMMARY_LABELS = {
+ settled_count: 'Piezas liquidadas',
+ count: 'Piezas liquidadas',
+ items_settled: 'Piezas liquidadas',
+ items_count: 'Piezas liquidadas',
+ total_sold: 'Total precio acordado',
+ total_agreed: 'Total precio acordado',
+ agreed_total: 'Total precio acordado',
+ total_commission: 'Comisión total',
+ commission_total: 'Comisión total',
+ total_payout: 'A pagar al consignante',
+ payout_total: 'A pagar al consignante',
+ consignor_payout: 'A pagar al consignante',
+ total_to_pay_consignor: 'A pagar al consignante',
+ consignor_name: 'Consignante',
+ total: 'Total',
+ status: 'Estado',
+ message: 'Resultado'
+ };
+ var MONEY_KEYS = /(price|amount|total|commission|payout|value|subtotal)/i;
+
+ function renderSettlementSummary(s) {
+ var keys = Object.keys(s || {}).filter(function (k) { return k !== 'items' && s[k] !== null && typeof s[k] !== 'object'; });
+ var html = 'Resumen de liquidación
';
+ if (!keys.length) html += '
La consigna quedó marcada como liquidada.
';
+ keys.forEach(function (k) {
+ var label = SUMMARY_LABELS[k] || k.replace(/_/g, ' ');
+ var v = s[k];
+ var shown = (typeof v === 'number' && MONEY_KEYS.test(k)) ? fmtMoney(v) : esc(v);
+ html += '
' + esc(label) + '' + shown + '
';
+ });
+ var items = Array.isArray(s.items) ? s.items : [];
+ if (items.length) {
+ html += '
' + items.map(function (it) {
+ return '
' +
+ esc([it.part_number, it.name].filter(Boolean).join(' · ') || ('ID ' + (it.inventory_id || '—'))) +
+ '' + fmtMoney(it.commission_amount || it.agreed_price) + '
';
+ }).join('') + '
';
+ }
+ return html + '
';
+ }
+
+ // =====================================================================
+ // EDIT PIECE (modal)
+ // =====================================================================
+
+ var editTarget = null;
+
+ function ynkOpenEditPiece(id) {
+ var it = pieceById[id];
+ if (!it) return;
+ openEditPiece(it);
+ }
+
+ function openEditPiece(it) {
+ editTarget = it;
+ document.getElementById('ynkEditId').value = it.id;
+ document.getElementById('ynkEditName').value = it.name || '';
+ document.getElementById('ynkEditPart').value = it.part_number || '';
+ document.getElementById('ynkEditBrand').value = it.brand || '';
+ document.getElementById('ynkEditPrice').value = it.price_1 !== undefined && it.price_1 !== null ? it.price_1 : '';
+ document.getElementById('ynkEditCost').value = it.cost !== undefined && it.cost !== null ? it.cost : '';
+ document.getElementById('ynkEditCondition').value = it.condition || 'usado';
+ document.getElementById('ynkEditGrade').value = it.condition_grade || '';
+ document.getElementById('ynkEditOwnership').value = it.ownership || 'propio';
+ document.getElementById('ynkEditLocation').value = it.location || '';
+ document.getElementById('ynkEditVin').value = it.donor_vin || '';
+ document.getElementById('ynkEditMake').value = it.donor_make || '';
+ document.getElementById('ynkEditModel').value = it.donor_model || '';
+ document.getElementById('ynkEditYear').value = it.donor_year || '';
+ document.getElementById('ynkEditKm').value = it.donor_mileage_km || '';
+ document.getElementById('ynkEditUnique').checked = !!it.is_unique_piece;
+ var res = document.getElementById('ynkEditResult');
+ if (res) { res.textContent = ''; res.className = 'ynk-save-result'; }
+ openModal('ynkEditModal');
+ }
+
+ function ynkSaveEditPiece() {
+ var id = document.getElementById('ynkEditId').value;
+ if (!id) return;
+ var res = document.getElementById('ynkEditResult');
+ var btn = document.getElementById('ynkEditSaveBtn');
+ var name = val('ynkEditName');
+ var price = numOrNull('ynkEditPrice');
+
+ if (!name) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'El nombre es obligatorio.'; }
+ return;
+ }
+ if (price === null) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'El precio es obligatorio.'; }
+ return;
+ }
+
+ var payload = {
+ name: name,
+ price_1: price,
+ condition: document.getElementById('ynkEditCondition').value,
+ ownership: document.getElementById('ynkEditOwnership').value,
+ is_unique_piece: !!document.getElementById('ynkEditUnique').checked,
+ condition_grade: document.getElementById('ynkEditGrade').value || null,
+ location: val('ynkEditLocation') || null,
+ donor_vin: (val('ynkEditVin') || '').toUpperCase() || null,
+ donor_make: val('ynkEditMake') || null,
+ donor_model: val('ynkEditModel') || null,
+ donor_year: intOrNull('ynkEditYear'),
+ donor_mileage_km: intOrNull('ynkEditKm'),
+ cost: numOrNull('ynkEditCost')
+ };
+
+ if (btn) btn.disabled = true;
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Guardando…'; }
+
+ apiFetch(API + '/pieces/' + id, { method: 'PUT', body: JSON.stringify(payload) })
+ .then(function (updated) {
+ var u = updated ? unwrap(updated) : null;
+ if (u && u.id) {
+ pieceById[u.id] = u;
+ } else if (editTarget) {
+ pieceById[editTarget.id] = Object.assign({}, editTarget, payload);
+ }
+ toast('Pieza actualizada', 'ok');
+ ynkCloseModal('ynkEditModal');
+ if (activeTab === 'piezas') loadPieces(1);
+ if (activeTab === 'donador' && donorView === 'pieces') loadDonorPieces(1);
+ refreshSummary();
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo guardar: ' + errMsg(err); }
+ toast('Error al guardar la pieza: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) btn.disabled = false; });
+ }
+
+ // =====================================================================
+ // PIECE IMAGES (modal)
+ // =====================================================================
+
+ var imagesTargetId = null;
+
+ function ynkOpenImages(id) {
+ var it = pieceById[id];
+ imagesTargetId = id;
+ document.getElementById('ynkImagesTitle').textContent = 'Fotos · ' + (it && it.name ? it.name : 'pieza #' + id);
+ var status = document.getElementById('ynkImagesStatus');
+ if (status) status.textContent = '';
+ renderImagesGrid(it);
+ openModal('ynkImagesModal');
+ }
+
+ function renderImagesGrid(it) {
+ var grid = document.getElementById('ynkImagesGrid');
+ if (!grid) return;
+ var imgs = (it && it.images) || [];
+ if (!imgs.length && it && it.image_url) imgs = [{ id: null, url: it.image_url, is_primary: true }];
+ if (!imgs.length) {
+ grid.innerHTML = 'Esta pieza no tiene fotos todavía.
';
+ return;
+ }
+ grid.innerHTML = imgs.map(function (im) {
+ return ' + ')
' +
+ (im.is_primary ? '
principal' : '') +
+ (canEdit() && im.id ? '
' : '') +
+ '
';
+ }).join('');
+ }
+
+ function ynkUploadImagesTo(id) {
+ return function (fileList) {
+ var files = Array.prototype.slice.call(fileList || []).filter(isAllowedImage);
+ if (!files.length) return;
+ var status = document.getElementById('ynkImagesStatus');
+ if (status) status.textContent = 'Subiendo ' + files.length + ' foto(s)…';
+ var fd = new FormData();
+ files.forEach(function (f) { fd.append('file', f, f.name); });
+ apiUpload(API + '/pieces/' + id + '/images', fd)
+ .then(function (resp) {
+ var it = pieceById[id] || {};
+ if (resp && Array.isArray(resp.images)) it.images = resp.images;
+ pieceById[id] = it;
+ renderImagesGrid(it);
+ if (status) status.textContent = 'Fotos actualizadas.';
+ toast('Fotos actualizadas', 'ok');
+ if (activeTab === 'piezas') loadPieces(1);
+ })
+ .catch(function (err) {
+ if (status) status.textContent = 'Error al subir: ' + errMsg(err);
+ toast('Error al subir fotos: ' + errMsg(err), 'error');
+ });
+ };
+ }
+
+ function ynkDeleteImage(imageId) {
+ var id = imagesTargetId;
+ if (!id || !imageId) return;
+ if (!window.confirm('¿Eliminar esta foto?')) return;
+ apiFetch(API + '/pieces/' + id + '/images/' + imageId, { method: 'DELETE' })
+ .then(function () {
+ var it = pieceById[id] || {};
+ it.images = (it.images || []).filter(function (im) { return im.id !== imageId; });
+ pieceById[id] = it;
+ renderImagesGrid(it);
+ toast('Foto eliminada', 'ok');
+ if (activeTab === 'piezas') loadPieces(1);
+ })
+ .catch(function (err) {
+ toast('Error al eliminar la foto: ' + errMsg(err), 'error');
+ });
+ }
+
+ // =====================================================================
+ // SHARED HELPERS — tolerant parsing + generic rendering
+ // The newer report/compat/duplicate endpoints do not pin every response
+ // key, so parse defensively and render whatever comes back.
+ // =====================================================================
+
+ var LIST_KEYS = ['data', 'items', 'rows', 'results', 'vehicles', 'compat',
+ 'compatibilities', 'compatibility', 'duplicates', 'groups', 'pieces'];
+
+ function anyList(res) {
+ if (!res) return [];
+ if (Array.isArray(res)) return res;
+ for (var i = 0; i < LIST_KEYS.length; i++) {
+ if (Array.isArray(res[LIST_KEYS[i]])) return res[LIST_KEYS[i]];
+ }
+ if (res.data && typeof res.data === 'object') {
+ for (var j = 0; j < LIST_KEYS.length; j++) {
+ if (Array.isArray(res.data[LIST_KEYS[j]])) return res.data[LIST_KEYS[j]];
+ }
+ }
+ return [];
+ }
+
+ var FIELD_LABELS = {
+ id: 'ID', part_number: 'No. Parte', name: 'Nombre', brand: 'Marca',
+ condition: 'Condición', condition_grade: 'Grado', grade: 'Grado',
+ price_1: 'Precio', price: 'Precio', cost: 'Costo', stock: 'Stock',
+ location: 'Ubicación', ownership: 'Propiedad', days_in_yard: 'Días en patio',
+ donor_vin: 'VIN', donor_make: 'Marca', donor_model: 'Modelo', donor_year: 'Año',
+ make: 'Marca', model: 'Modelo', year: 'Año', engine: 'Motor',
+ inventory_id: 'ID Inv.', pieces: 'Piezas', count: 'Cantidad', qty: 'Cantidad',
+ total: 'Total', total_value: 'Valor', value: 'Valor', reason: 'Motivo',
+ score: 'Similitud', similarity: 'Similitud', status: 'Estado',
+ agreed_price: 'Precio acordado', commission_amount: 'Comisión'
+ };
+ function fieldLabel(k) { return FIELD_LABELS[k] || String(k).replace(/_/g, ' '); }
+ function isMoneyKey(k) {
+ return /(price|cost|amount|total|value|margin|margen|capital|payout|commission|precio|costo)/i.test(k);
+ }
+ function formatCell(k, v) {
+ if (v === null || v === undefined || v === '') return '—';
+ if (k === 'condition_grade' || k === 'grade') return gradeHtml(v);
+ if (typeof v === 'number') return isMoneyKey(k) ? fmtMoney(v) : esc(v);
+ if (typeof v === 'object') return esc(JSON.stringify(v));
+ return esc(v);
+ }
+
+ function genericTableHtml(items, columns) {
+ if (!items || !items.length) return 'Sin resultados.
';
+ var present = {};
+ items.forEach(function (it) {
+ if (it && typeof it === 'object') Object.keys(it).forEach(function (k) { present[k] = 1; });
+ });
+ var cols = (columns || []).filter(function (c) { return present[c]; });
+ if (!cols.length) {
+ var seen = {};
+ items.forEach(function (it) {
+ if (!it || typeof it !== 'object') return;
+ Object.keys(it).forEach(function (k) {
+ if (k !== 'images' && !seen[k]) { seen[k] = 1; cols.push(k); }
+ });
+ });
+ }
+ if (!cols.length) {
+ return '' + esc(JSON.stringify(items, null, 2)) + '
';
+ }
+ return '' + cols.map(function (c) {
+ return '| ' + esc(fieldLabel(c)) + ' | ';
+ }).join('') + '
' + items.map(function (it) {
+ return '' + cols.map(function (c) {
+ return '| ' + formatCell(c, it ? it[c] : null) + ' | ';
+ }).join('') + '
';
+ }).join('') + '
';
+ }
+
+ function kvHtml(obj, title) {
+ var keys = Object.keys(obj || {}).filter(function (k) {
+ var v = obj[k];
+ return v === null || v === undefined || typeof v !== 'object';
+ });
+ var html = '' + esc(title) + '
';
+ if (!keys.length) html += '
Sin datos.
';
+ keys.forEach(function (k) {
+ var v = obj[k];
+ var shown = (typeof v === 'number' && isMoneyKey(k)) ? fmtMoney(v) : esc(v);
+ html += '
' + esc(fieldLabel(k)) +
+ '' + shown + '
';
+ });
+ return html + '
';
+ }
+
+ function anyCard(title, value) {
+ if (value === undefined) {
+ return '' + esc(title) + '
Sin datos.
';
+ }
+ if (Array.isArray(value)) {
+ return '' + esc(title) + '
' +
+ '
' + genericTableHtml(value) + '
';
+ }
+ if (value && typeof value === 'object') return kvHtml(value, title);
+ var shown = (typeof value === 'number' && isMoneyKey(title)) ? fmtMoney(value) : esc(value);
+ return '' + esc(title) + '
' +
+ '
' + shown + '
';
+ }
+
+ function summaryInline(summary) {
+ if (summary === null || summary === undefined || summary === '') return '';
+ if (typeof summary !== 'object') return String(summary);
+ var parts = Object.keys(summary).filter(function (k) {
+ return summary[k] === null || typeof summary[k] !== 'object';
+ }).map(function (k) {
+ var v = summary[k];
+ return fieldLabel(k) + ': ' + ((typeof v === 'number' && isMoneyKey(k)) ? fmtMoney(v) : v);
+ });
+ return parts.join(' · ');
+ }
+
+ // =====================================================================
+ // PIECE DETAIL / ACTIONS (ficha)
+ // =====================================================================
+
+ var currentDetailId = null;
+ var identifiedName = '';
+ var suggestedPrice = null;
+
+ function fetchPiece(id) {
+ return apiFetch(API + '/pieces/' + id)
+ .then(function (res) { return unwrap(res); })
+ .catch(function (err) { toast('No se pudo cargar la pieza: ' + errMsg(err), 'error'); return null; });
+ }
+
+ function ynkOpenPiece(id, action) {
+ var it = pieceById[id];
+ if (it) {
+ renderPieceDetail(it);
+ } else {
+ document.getElementById('ynkPieceTitle').textContent = 'Pieza #' + id;
+ document.getElementById('ynkPieceInfo').textContent = 'Cargando…';
+ document.getElementById('ynkPiecePhotos').innerHTML = '';
+ }
+ openModal('ynkPieceModal');
+ var p = it ? Promise.resolve(it) : fetchPiece(id);
+ p.then(function (full) {
+ if (!full || !full.id) return;
+ pieceById[full.id] = full;
+ renderPieceDetail(full);
+ if (action === 'meli') ynkPublishMeli();
+ }).catch(function () {});
+ }
+
+ function renderPieceDetail(it) {
+ currentDetailId = it.id;
+ document.getElementById('ynkPieceTitle').textContent = it.name || ('Pieza #' + it.id);
+ var veh = [it.donor_make, it.donor_model, it.donor_year].filter(Boolean).join(' ');
+ var info = document.getElementById('ynkPieceInfo');
+ info.innerHTML =
+ 'SKU / No. parte' + (esc(it.part_number) || '—') + '
' +
+ 'Marca' + (esc(it.brand) || '—') + '
' +
+ 'Condición / grado' + esc(it.condition || '—') + ' · ' + gradeHtml(it.condition_grade) + '
' +
+ 'Precio / costo' + fmtMoney(it.price_1) + ' / ' + fmtMoney(it.cost) + '
' +
+ 'Stock' + fmtInt(it.stock) + (it.is_unique_piece ? ' (1 de 1)' : '') + '
' +
+ 'Ubicación' + (esc(it.location) || '—') + '
' +
+ 'Propiedad' + esc(it.ownership || 'propio') + '
' +
+ 'Donador' + (esc(veh) || '—') + (it.donor_vin ? ' · ' + esc(it.donor_vin) : '') + '
';
+
+ renderPiecePhotos(it);
+ pieceActionResult('', '');
+ var pres = document.getElementById('ynkCompatResult');
+ if (pres) { pres.textContent = ''; pres.className = 'ynk-save-result'; }
+ ['ynkCompatMake', 'ynkCompatModel', 'ynkCompatYear', 'ynkCompatEngine'].forEach(function (i) {
+ var el = document.getElementById(i); if (el) el.value = '';
+ });
+ loadCompat(it.id);
+ }
+
+ function renderPiecePhotos(it) {
+ var grid = document.getElementById('ynkPiecePhotos');
+ if (!grid) return;
+ var imgs = (it && it.images) || [];
+ if (!imgs.length && it && it.image_url) imgs = [{ url: it.image_url }];
+ if (!imgs.length) { grid.innerHTML = 'Sin fotos.'; return; }
+ grid.innerHTML = imgs.map(function (im) {
+ return '';
+ }).join('');
+ }
+
+ function pieceActionResult(html, cls) {
+ var res = document.getElementById('ynkPieceActionResult');
+ if (!res) return;
+ res.className = 'ynk-save-result' + (cls ? ' ' + cls : '');
+ res.innerHTML = html;
+ }
+
+ function ynkPublishMeli() {
+ var id = currentDetailId;
+ if (!id) return;
+ var body = {};
+ var cat = val('ynkMeliCategory'); if (cat) body.category_id = cat;
+ var price = numOrNull('ynkMeliPrice'); if (price !== null) body.price = price;
+ var stock = intOrNull('ynkMeliStock'); if (stock !== null) body.stock = stock;
+
+ pieceActionResult('Publicando en MercadoLibre…');
+ apiFetch(API + '/pieces/' + id + '/publish-meli', { method: 'POST', body: JSON.stringify(body) })
+ .then(function (d) {
+ d = unwrap(d) || {};
+ if (d.ok) {
+ var link = d.permalink
+ ? ' ' + esc(d.permalink) + ''
+ : '';
+ pieceActionResult('✔ Publicado en MeLi. Item: ' + esc(d.external_item_id || '—') + '' + link);
+ toast('Pieza publicada en MercadoLibre', 'ok');
+ } else {
+ pieceActionResult('No se pudo publicar: ' + esc(d.error || 'la plataforma rechazó la publicación') + '');
+ toast('No se pudo publicar en MeLi', 'error');
+ }
+ })
+ .catch(function (err) {
+ pieceActionResult('Error al publicar: ' + esc(errMsg(err)) + '');
+ toast('Error al publicar en MeLi: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkOpenLabel(id) {
+ var pid = id || currentDetailId;
+ if (!pid) return;
+ window.open(API + '/pieces/' + pid + '/label', '_blank', 'noopener');
+ }
+
+ function ynkEditFromDetail() {
+ var it = pieceById[currentDetailId];
+ if (!it) return;
+ ynkCloseModal('ynkPieceModal');
+ openEditPiece(it);
+ }
+
+ function ynkIdentifyFile(file) {
+ var id = currentDetailId;
+ if (!id || !file) return;
+ var fd = new FormData();
+ fd.append('file', file, file.name);
+ pieceActionResult('Analizando la foto…');
+ apiUpload(API + '/pieces/' + id + '/identify', fd)
+ .then(function (raw) {
+ var d = unwrap(raw) || {};
+ identifiedName = (d.name || '').trim();
+ var conf = (typeof d.confidence === 'number') ? Math.round(d.confidence * 100) + '%' : '';
+ if (!identifiedName && !d.category) {
+ pieceActionResult('La IA no reconoció la pieza' +
+ (d.error ? ': ' + esc(d.error) : '.') + '');
+ return;
+ }
+ var html = 'Identificado: ' + (esc(identifiedName) || '—') + '' +
+ (d.category ? ' · Categoría: ' + esc(d.category) : '') +
+ (conf ? ' · confianza ' + esc(conf) : '');
+ if (identifiedName) {
+ html += ' ';
+ }
+ pieceActionResult(html);
+ })
+ .catch(function (err) {
+ pieceActionResult('No se pudo identificar: ' + esc(errMsg(err)) + '');
+ });
+ }
+
+ function ynkApplyIdentified() {
+ var id = currentDetailId;
+ if (!id || !identifiedName) return;
+ apiFetch(API + '/pieces/' + id, { method: 'PUT', body: JSON.stringify({ name: identifiedName }) })
+ .then(function (u) {
+ var item = unwrap(u);
+ if (item && item.id) pieceById[item.id] = item;
+ else if (pieceById[id]) pieceById[id].name = identifiedName;
+ pieceActionResult('✔ Nombre aplicado.');
+ toast('Nombre actualizado', 'ok');
+ if (activeTab === 'piezas') loadPieces(1);
+ if (activeTab === 'donador' && donorView === 'pieces') loadDonorPieces(1);
+ })
+ .catch(function (err) {
+ pieceActionResult('No se pudo aplicar: ' + esc(errMsg(err)) + '');
+ });
+ }
+
+ function ynkSuggestPrice() {
+ var id = currentDetailId;
+ if (!id) return;
+ pieceActionResult('Calculando precio sugerido…');
+ apiFetch(API + '/pieces/' + id + '/price-suggest', { method: 'POST', body: JSON.stringify({}) })
+ .then(function (raw) {
+ var d = unwrap(raw) || {};
+ suggestedPrice = (d.suggested_price === undefined || d.suggested_price === null) ? null : d.suggested_price;
+ if (suggestedPrice === null && d.low === undefined) {
+ pieceActionResult('No hay sugerencia disponible' +
+ (d.error ? ': ' + esc(d.error) : '.') + '');
+ return;
+ }
+ var range = (d.low !== undefined && d.high !== undefined)
+ ? ' · Rango: ' + fmtMoney(d.low) + ' – ' + fmtMoney(d.high) : '';
+ var html = 'Sugerido: ' + fmtMoney(suggestedPrice) + '' + range +
+ (d.basis ? '
' + esc(d.basis) + '' : '');
+ if (suggestedPrice !== null) {
+ html += ' ';
+ }
+ pieceActionResult(html);
+ })
+ .catch(function (err) {
+ pieceActionResult('No se pudo sugerir precio: ' + esc(errMsg(err)) + '');
+ });
+ }
+
+ function ynkApplySuggestedPrice() {
+ var id = currentDetailId;
+ if (!id || suggestedPrice === null) return;
+ apiFetch(API + '/pieces/' + id, { method: 'PUT', body: JSON.stringify({ price_1: suggestedPrice }) })
+ .then(function (u) {
+ var item = unwrap(u);
+ if (item && item.id) pieceById[item.id] = item;
+ else if (pieceById[id]) pieceById[id].price_1 = suggestedPrice;
+ toast('Precio aplicado: ' + fmtMoney(suggestedPrice), 'ok');
+ pieceActionResult('✔ Precio actualizado a ' + fmtMoney(suggestedPrice) + '.');
+ if (activeTab === 'piezas') loadPieces(1);
+ })
+ .catch(function (err) {
+ pieceActionResult('No se pudo aplicar: ' + esc(errMsg(err)) + '');
+ });
+ }
+
+ function ynkValidatePhoto(file) {
+ var id = currentDetailId;
+ if (!id || !file) return;
+ var fd = new FormData();
+ fd.append('file', file, file.name);
+ pieceActionResult('Validando la foto…');
+ apiUpload(API + '/pieces/' + id + '/validate-photo', fd)
+ .then(function (raw) {
+ var d = unwrap(raw) || {};
+ var issues = d.issues || d.problems || [];
+ if (d.ok && !(issues && issues.length)) {
+ pieceActionResult('✔ La foto parece correcta.');
+ } else {
+ var list = (issues && issues.length)
+ ? issues.map(function (s) { return '' + esc(typeof s === 'object' ? (s.message || JSON.stringify(s)) : s) + ''; }).join('')
+ : '' + esc(d.error || 'no cumple los requisitos') + '';
+ pieceActionResult('Revisar la foto:');
+ }
+ })
+ .catch(function (err) {
+ pieceActionResult('No se pudo validar: ' + esc(errMsg(err)) + '');
+ });
+ }
+
+ function loadCompat(id) {
+ var box = document.getElementById('ynkCompatList');
+ if (!box) return;
+ box.innerHTML = 'Cargando compatibilidad…';
+ apiFetch(API + '/pieces/' + id + '/compat')
+ .then(function (res) {
+ var items = anyList(res);
+ if (!items.length) { box.innerHTML = 'Sin compatibilidades registradas.'; return; }
+ box.innerHTML = '' +
+ genericTableHtml(items, ['make', 'model', 'year', 'engine']) + '
';
+ })
+ .catch(function (err) {
+ box.innerHTML = 'No se pudo cargar la compatibilidad: ' + esc(errMsg(err)) + '';
+ });
+ }
+
+ function ynkAddCompat() {
+ var id = currentDetailId;
+ if (!id) return;
+ var make = val('ynkCompatMake'), model = val('ynkCompatModel');
+ var year = intOrNull('ynkCompatYear'), engine = val('ynkCompatEngine');
+ var res = document.getElementById('ynkCompatResult');
+ if (!make || !model || year === null) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'Marca, modelo y año son obligatorios.'; }
+ return;
+ }
+ var body = { make: make, model: model, year: year };
+ if (engine) body.engine = engine;
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Agregando…'; }
+ apiFetch(API + '/pieces/' + id + '/compat', { method: 'POST', body: JSON.stringify(body) })
+ .then(function () {
+ if (res) { res.className = 'ynk-save-result ynk-ok'; res.textContent = '✔ Compatibilidad agregada.'; }
+ toast('Compatibilidad agregada', 'ok');
+ ['ynkCompatMake', 'ynkCompatModel', 'ynkCompatYear', 'ynkCompatEngine'].forEach(function (i) {
+ var el = document.getElementById(i); if (el) el.value = '';
+ });
+ loadCompat(id);
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo agregar: ' + errMsg(err); }
+ });
+ }
+
+ // =====================================================================
+ // CONSIGNMENT STATEMENT (estado de cuenta)
+ // =====================================================================
+
+ var currentStatementId = null;
+
+ function ynkOpenStatement(id) {
+ currentStatementId = id;
+ document.getElementById('ynkStatementTitle').textContent = 'Estado de cuenta · Consignación #' + id;
+ document.getElementById('ynkStatementInfo').textContent = '';
+ document.getElementById('ynkStatementBody').innerHTML = 'Cargando estado de cuenta…
';
+ var from = document.getElementById('ynkStmtFrom');
+ var to = document.getElementById('ynkStmtTo');
+ if (from && !from.value) {
+ var d = new Date();
+ from.value = new Date(d.getFullYear(), d.getMonth(), 1).toISOString().substring(0, 10);
+ to.value = new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().substring(0, 10);
+ }
+ openModal('ynkStatementModal');
+ ynkLoadStatement();
+ }
+
+ function statementParams() {
+ return qs({ from: val('ynkStmtFrom'), to: val('ynkStmtTo') });
+ }
+
+ function ynkLoadStatement() {
+ if (!currentStatementId) return;
+ var body = document.getElementById('ynkStatementBody');
+ if (body) body.innerHTML = 'Cargando estado de cuenta…
';
+ var params = statementParams();
+ apiFetch(API + '/consignments/' + currentStatementId + '/statement' + (params ? '?' + params : ''))
+ .then(function (res) {
+ var s = unwrap(res) || {};
+ var info = document.getElementById('ynkStatementInfo');
+ if (info) {
+ var period = s.period && typeof s.period === 'object' ? JSON.stringify(s.period) : s.period;
+ info.innerHTML = '' + esc(s.consignor_name || '—') + '' +
+ (period ? ' · Periodo: ' + esc(period) : '');
+ }
+ if (!body) return;
+ var items = anyList(res);
+ var html = items.length
+ ? genericTableHtml(items, ['inventory_id', 'part_number', 'name', 'condition_grade', 'price_1', 'cost', 'agreed_price', 'commission_amount', 'status'])
+ : 'Sin movimientos en el periodo.
';
+ if (s.totals && typeof s.totals === 'object') html += kvHtml(s.totals, 'Totales');
+ else if (s.totals !== undefined) html += 'Totales: ' + esc(s.totals) + '
';
+ body.innerHTML = html;
+ })
+ .catch(function (err) {
+ if (body) body.innerHTML = 'No se pudo cargar el estado de cuenta: ' + esc(errMsg(err)) + '
';
+ });
+ }
+
+ function ynkPrintStatement() {
+ if (!currentStatementId) return;
+ var params = statementParams();
+ window.open(API + '/consignments/' + currentStatementId + '/statement/print' + (params ? '?' + params : ''), '_blank', 'noopener');
+ }
+
+ // =====================================================================
+ // TAB 5 — REPORTES
+ // =====================================================================
+
+ var reportView = 'aging';
+
+ function ynkSetReportView(view) {
+ reportView = view === 'summary' ? 'summary' : (view === 'duplicates' ? 'duplicates' : 'aging');
+ var segs = { aging: 'ynkRepSegAging', summary: 'ynkRepSegSummary', duplicates: 'ynkRepSegDup' };
+ Object.keys(segs).forEach(function (k) {
+ var el = document.getElementById(segs[k]);
+ if (el) el.classList.toggle('is-active', k === reportView);
+ });
+ document.getElementById('ynkRepAging').style.display = reportView === 'aging' ? '' : 'none';
+ document.getElementById('ynkRepSummary').style.display = reportView === 'summary' ? '' : 'none';
+ document.getElementById('ynkRepDup').style.display = reportView === 'duplicates' ? '' : 'none';
+ if (reportView === 'aging') ynkLoadAging();
+ else if (reportView === 'summary') ynkLoadReportSummary();
+ else ynkLoadDuplicates();
+ }
+
+ function ynkLoadAging() {
+ var body = document.getElementById('ynkAgingBody');
+ if (!body) return;
+ var days = intOrNull('ynkAgingDays');
+ if (days === null) days = 90;
+ body.innerHTML = emptyRow(7, 'Cargando antigüedad…');
+ apiFetch(API + '/reports/aging?days=' + days)
+ .then(function (res) {
+ res = res || {};
+ var items = Array.isArray(res.data) ? res.data : anyList(res);
+ var summary = document.getElementById('ynkAgingSummary');
+ if (summary) summary.textContent = summaryInline(res.summary);
+ if (!items.length) {
+ body.innerHTML = emptyRow(7, 'Sin piezas con ' + days + '+ días en patio.');
+ document.getElementById('ynkAgingFooter').textContent = '0 piezas';
+ return;
+ }
+ body.innerHTML = items.map(function (it) {
+ return '' +
+ '| ' + esc(it.id) + ' | ' +
+ '' + (esc(it.part_number) || '—') + ' | ' +
+ '' + esc(it.name) + ' | ' +
+ '' + gradeHtml(it.condition_grade) + ' | ' +
+ '' + fmtMoney(it.price_1) + ' | ' +
+ '' + fmtMoney(it.cost) + ' | ' +
+ '' + fmtInt(it.days_in_yard) + ' | ' +
+ '
';
+ }).join('');
+ document.getElementById('ynkAgingFooter').textContent = items.length + ' pieza(s)';
+ })
+ .catch(function (err) {
+ body.innerHTML = emptyRow(7, 'Error al cargar antigüedad: ' + errMsg(err));
+ });
+ }
+
+ function ynkLoadReportSummary() {
+ var box = document.getElementById('ynkRepSummaryBody');
+ if (!box) return;
+ box.innerHTML = 'Cargando resumen…
';
+ apiFetch(API + '/reports/summary')
+ .then(function (res) {
+ var s = unwrap(res) || {};
+ var known = ['capital', 'margin', 'without_photo', 'by_grade', 'by_donor'];
+ var hasKnown = known.some(function (k) { return s[k] !== undefined; });
+ var html = '';
+ if (hasKnown) {
+ html += anyCard('Capital en inventario', s.capital);
+ html += anyCard('Margen', s.margin);
+ html += anyCard('Sin foto', s.without_photo);
+ html += anyCard('Piezas por grado', s.by_grade);
+ html += anyCard('Piezas por donador', s.by_donor);
+ } else {
+ html = anyCard('Resumen', s);
+ }
+ box.innerHTML = html || 'Sin datos.
';
+ var upd = document.getElementById('ynkRepSummaryUpdated');
+ if (upd) upd.textContent = 'Actualizado ' + new Date().toLocaleTimeString('es-MX');
+ })
+ .catch(function (err) {
+ box.innerHTML = 'Error al cargar resumen: ' + esc(errMsg(err)) + '
';
+ });
+ }
+
+ function duplicateSummary(it) {
+ if (!it || typeof it !== 'object') return esc(it);
+ var label = it.name || it.part_number || it.key || it.signature || it.label;
+ return esc(label || 'Grupo') + (it.count !== undefined ? ' (' + esc(it.count) + ')' : '');
+ }
+
+ function duplicateDetail(it) {
+ if (it && Array.isArray(it.pieces) && it.pieces.length) {
+ return '' + it.pieces.map(function (p) {
+ return '
#' + esc(p.id) + ' ' + esc(p.part_number || '') +
+ '' + esc(p.name || '') + ' · ' + fmtMoney(p.price_1) + '
';
+ }).join('') + '
';
+ }
+ if (it && typeof it === 'object') {
+ var parts = Object.keys(it).filter(function (k) {
+ var v = it[k];
+ return v === null || typeof v !== 'object';
+ }).map(function (k) {
+ var v = it[k];
+ return fieldLabel(k) + ': ' + ((typeof v === 'number' && isMoneyKey(k)) ? fmtMoney(v) : v);
+ });
+ return '' + esc(parts.join(' · ')) + '';
+ }
+ return esc(it);
+ }
+
+ function ynkLoadDuplicates() {
+ var body = document.getElementById('ynkDupBody');
+ if (!body) return;
+ var th = numOrNull('ynkDupThreshold');
+ body.innerHTML = emptyRow(3, 'Buscando duplicados…');
+ apiFetch(API + '/duplicates' + (th !== null ? '?threshold=' + th : ''))
+ .then(function (res) {
+ var items = anyList(res);
+ if (!items.length) {
+ body.innerHTML = emptyRow(3, 'No se encontraron duplicados con ese umbral.');
+ document.getElementById('ynkDupFooter').textContent = '0 coincidencias';
+ return;
+ }
+ var grouped = items.some(function (it) { return it && Array.isArray(it.pieces); });
+ if (grouped) {
+ body.innerHTML = items.map(function (it, i) {
+ return '| ' + (i + 1) + ' | ' +
+ '' + duplicateSummary(it) + ' | ' +
+ '' + duplicateDetail(it) + ' |
';
+ }).join('');
+ } else {
+ body.innerHTML = '' +
+ genericTableHtml(items) + ' |
';
+ }
+ document.getElementById('ynkDupFooter').textContent = items.length + ' coincidencia(s)';
+ })
+ .catch(function (err) {
+ body.innerHTML = emptyRow(3, 'Error al buscar duplicados: ' + errMsg(err));
+ });
+ }
+
+ // =====================================================================
+ // CSV IMPORT (pieces/bulk)
+ // =====================================================================
+
+ function ynkOpenImportModal() {
+ var f = document.getElementById('ynkImportFile');
+ if (f) f.value = '';
+ var n = document.getElementById('ynkImportFileName');
+ if (n) n.textContent = 'Sin archivo seleccionado';
+ var r = document.getElementById('ynkImportResult');
+ if (r) { r.textContent = ''; r.className = 'ynk-save-result'; }
+ openModal('ynkImportModal');
+ }
+
+ function postImport(field, file) {
+ var fd = new FormData();
+ fd.append(field, file, file.name);
+ return apiUpload(API + '/pieces/bulk', fd);
+ }
+
+ function renderImportResult(resp) {
+ var res = document.getElementById('ynkImportResult');
+ if (!res) return;
+ var d = unwrap(resp) || {};
+ var created = d.created !== undefined ? d.created
+ : (d.inserted !== undefined ? d.inserted
+ : (d.imported !== undefined ? d.imported
+ : (d.count !== undefined ? d.count : undefined)));
+ var errors = d.errors || d.error_rows || d.failed || [];
+ var html = '';
+ if (created !== undefined) html += '✔ Creadas: ' + esc(created) + ' pieza(s).
';
+ if (Array.isArray(errors) && errors.length) {
+ html += '' + errors.length + ' error(es):
' +
+ '' + errors.map(function (e) {
+ if (e && typeof e === 'object') {
+ var rowLabel = e.row !== undefined ? 'Fila ' + e.row : (e.line !== undefined ? 'Línea ' + e.line : '—');
+ return '
' + esc(rowLabel) + '' +
+ esc(e.error || e.message || JSON.stringify(e)) + '
';
+ }
+ return '
' + esc(e) + '
';
+ }).join('') + '
';
+ } else if (errors && !Array.isArray(errors)) {
+ html += '' + esc(errors) + '
';
+ }
+ if (!html) html = 'Respuesta: ' + esc(JSON.stringify(d)) + '';
+ res.className = 'ynk-save-result';
+ res.innerHTML = html;
+ }
+
+ function ynkSubmitImport() {
+ var input = document.getElementById('ynkImportFile');
+ var file = input && input.files && input.files[0];
+ var res = document.getElementById('ynkImportResult');
+ var btn = document.getElementById('ynkImportBtn');
+ if (!file) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'Selecciona un archivo CSV.'; }
+ return;
+ }
+ if (btn) { btn.disabled = true; btn.textContent = 'Importando…'; }
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Importando ' + file.name + '…'; }
+
+ postImport('file', file)
+ .catch(function (err) {
+ // A missing-file 400 usually means the backend reads the part
+ // under the `csv` field instead of `file`; retry once. Only for
+ // a missing-file error, so nothing is imported twice.
+ var msg = ((err && err.body && (err.body.error || err.body.message)) || '').toLowerCase();
+ if (err && err.status === 400 && /file|csv|multipart|missing|required|no se recib/.test(msg)) {
+ return postImport('csv', file);
+ }
+ throw err;
+ })
+ .then(function (resp) {
+ renderImportResult(resp);
+ toast('Importación procesada', 'ok');
+ refreshSummary();
+ if (activeTab === 'piezas') loadPieces(1);
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo importar: ' + errMsg(err); }
+ toast('Error al importar: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) { btn.disabled = false; btn.textContent = 'Importar'; } });
+ }
+
+ // =====================================================================
+ // INTAKE MODE — single piece / full donor (varias piezas)
+ // =====================================================================
+
+ var intakeMode = 'single';
+ var GRADE_OPTS = '' +
+ '';
+
+ function ynkSetIntakeMode(mode) {
+ intakeMode = mode === 'full' ? 'full' : 'single';
+ document.getElementById('ynkSegSingle').classList.toggle('is-active', intakeMode === 'single');
+ document.getElementById('ynkSegFull').classList.toggle('is-active', intakeMode === 'full');
+ document.getElementById('ynkNuevaSimple').style.display = intakeMode === 'single' ? '' : 'none';
+ document.getElementById('ynkNuevaFull').style.display = intakeMode === 'full' ? '' : 'none';
+ if (intakeMode === 'full') {
+ fillConsignmentSelect('ynkFullConsignmentId');
+ var rows = document.getElementById('ynkFullPiecesBody');
+ if (rows && !rows.children.length) {
+ for (var i = 0; i < 3; i++) ynkFullAddRow();
+ }
+ }
+ }
+
+ function ynkFullRowHtml() {
+ return '' +
+ ' | ' +
+ ' | ' +
+ ' | ' +
+ ' | ' +
+ ' | ' +
+ ' | ' +
+ '
';
+ }
+
+ function ynkFullAddRow() {
+ var body = document.getElementById('ynkFullPiecesBody');
+ if (!body) return;
+ body.insertAdjacentHTML('beforeend', ynkFullRowHtml());
+ }
+
+ function ynkFullRemoveRow(btn) {
+ var row = btn && btn.closest ? btn.closest('tr') : null;
+ if (row && row.parentNode) row.parentNode.removeChild(row);
+ }
+
+ function ynkFullClearRows() {
+ var body = document.getElementById('ynkFullPiecesBody');
+ if (body) body.innerHTML = '';
+ ynkFullAddRow();
+ }
+
+ function ynkFullResetForm() {
+ ynkFullClearRows();
+ var r = document.getElementById('ynkFullResult');
+ if (r) { r.textContent = ''; r.className = 'ynk-save-result'; }
+ }
+
+ function ynkFullDecodeVin() {
+ var vin = val('ynkFullVin').toUpperCase();
+ var status = document.getElementById('ynkFullVinStatus');
+ if (!vin) {
+ if (status) { status.textContent = 'Escribe un VIN primero.'; status.className = 'ynk-hint ynk-warn'; }
+ return;
+ }
+ var btn = document.getElementById('ynkFullDecodeBtn');
+ if (btn) btn.disabled = true;
+ if (status) { status.textContent = 'Decodificando ' + vin + '…'; status.className = 'ynk-hint'; }
+ apiFetch(API + '/vin/' + encodeURIComponent(vin))
+ .then(function (d) {
+ if (!d) return;
+ var filled = [];
+ if (d.make) { document.getElementById('ynkFullMake').value = d.make; filled.push(d.make); }
+ if (d.model) { document.getElementById('ynkFullModel').value = d.model; filled.push(d.model); }
+ if (d.year) { document.getElementById('ynkFullYear').value = d.year; filled.push(d.year); }
+ if (status) {
+ status.textContent = filled.length ? ('Autocompletado: ' + filled.join(' '))
+ : (d.error ? 'No se pudo decodificar: ' + d.error : 'VIN sin datos de vehículo.');
+ status.className = 'ynk-hint' + (filled.length ? ' ynk-ok' : ' ynk-warn');
+ }
+ })
+ .catch(function (err) {
+ if (status) { status.textContent = 'No se pudo decodificar: ' + errMsg(err); status.className = 'ynk-hint ynk-err'; }
+ })
+ .then(function () { if (btn) btn.disabled = false; });
+ }
+
+ function ynkReadFullRows() {
+ var rows = document.querySelectorAll('#ynkFullPiecesBody tr.ynk-full-row');
+ var out = [];
+ Array.prototype.forEach.call(rows, function (tr) {
+ var name = (tr.querySelector('.ynk-full-name').value || '').trim();
+ var priceStr = (tr.querySelector('.ynk-full-price').value || '').trim();
+ if (!name && priceStr === '') return; // skip fully blank rows
+ var piece = { name: name, price_1: priceStr === '' ? null : parseFloat(priceStr) };
+ var cost = (tr.querySelector('.ynk-full-cost').value || '').trim();
+ if (cost !== '') piece.cost = parseFloat(cost);
+ var grade = tr.querySelector('.ynk-full-grade').value;
+ if (grade) piece.condition_grade = grade;
+ var loc = (tr.querySelector('.ynk-full-location').value || '').trim();
+ if (loc) piece.location = loc;
+ out.push(piece);
+ });
+ return out;
+ }
+
+ function ynkSubmitDonorFull() {
+ var result = document.getElementById('ynkFullResult');
+ var btn = document.getElementById('ynkFullSaveBtn');
+ var vin = val('ynkFullVin').toUpperCase();
+ var make = val('ynkFullMake');
+ var model = val('ynkFullModel');
+ var year = intOrNull('ynkFullYear');
+
+ function fail(msg) {
+ if (result) { result.className = 'ynk-save-result ynk-err'; result.textContent = msg; }
+ }
+ if (!vin || !make || !model || year === null) {
+ fail('VIN, marca, modelo y año del donador son obligatorios.'); return;
+ }
+ var pieces = ynkReadFullRows();
+ if (!pieces.length) { fail('Agrega al menos una pieza con nombre y precio.'); return; }
+ var bad = pieces.filter(function (p) { return !p.name || p.price_1 === null || isNaN(p.price_1); });
+ if (bad.length) { fail('Cada pieza necesita nombre y precio válidos.'); return; }
+
+ var body = { vin: vin, make: make, model: model, year: year, pieces: pieces };
+ var mileage = intOrNull('ynkFullMileage'); if (mileage !== null) body.mileage_km = mileage;
+ var color = val('ynkFullColor'); if (color) body.color = color;
+ var ownership = document.getElementById('ynkFullOwnership').value;
+ if (ownership) body.ownership = ownership;
+ if (ownership === 'consigna') {
+ var cid = document.getElementById('ynkFullConsignmentId').value;
+ if (cid) body.consignment_id = parseInt(cid, 10);
+ }
+
+ if (btn) { btn.disabled = true; btn.textContent = 'Registrando…'; }
+ if (result) { result.className = 'ynk-save-result'; result.textContent = 'Registrando ' + pieces.length + ' pieza(s) del donador ' + vin + '…'; }
+
+ apiFetch(API + '/donors/full', { method: 'POST', body: JSON.stringify(body) })
+ .then(function (res) {
+ var d = unwrap(res) || {};
+ var created = Array.isArray(d.pieces) ? d.pieces : [];
+ var skus = created.map(function (p) { return p.part_number; }).filter(Boolean);
+ var count = d.count !== undefined ? d.count : skus.length;
+ var donorVin = d.donor_vin || vin;
+ if (result) {
+ result.className = 'ynk-save-result ynk-ok';
+ result.innerHTML = '✔ Donador ' + esc(donorVin) + ': ' + esc(count) + ' pieza(s) creada(s).' +
+ (skus.length ? 'SKUs: ' + skus.map(function (s) { return esc(s); }).join(', ') + '
' : '');
+ }
+ toast(count + ' pieza(s) creadas', 'ok');
+ ynkFullClearRows();
+
+ var totalCost = numOrNull('ynkFullTotalCost');
+ if (totalCost !== null && totalCost > 0 &&
+ window.confirm('¿Prorratear el costo total ' + fmtMoney(totalCost) + ' entre las piezas del donador? Esto actualiza el costo de todas sus piezas.')) {
+ apiFetch(API + '/donors/' + encodeURIComponent(donorVin) + '/allocate-cost',
+ { method: 'POST', body: JSON.stringify({ total_cost: totalCost }) })
+ .then(function (r2) {
+ var a = unwrap(r2) || {};
+ if (result) result.innerHTML += 'Costo prorrateado' +
+ (a.updated !== undefined ? ' en ' + esc(a.updated) + ' pieza(s)' : '') + '.
';
+ })
+ .catch(function (err) {
+ if (result) result.innerHTML += 'No se pudo prorratear el costo: ' + esc(errMsg(err)) + '
';
+ });
+ }
+ refreshSummary();
+ if (activeTab === 'piezas') loadPieces(1);
+ })
+ .catch(function (err) {
+ if (result) { result.className = 'ynk-save-result ynk-err'; result.textContent = 'No se pudo registrar el donador: ' + errMsg(err); }
+ toast('Error al registrar el donador: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) { btn.disabled = false; btn.textContent = 'Registrar donador y piezas'; } });
+ }
+
+ function ynkSuggestGradeFromPhoto() {
+ var status = document.getElementById('ynkGradeSuggestStatus');
+ var btn = document.getElementById('ynkGradeSuggestBtn');
+ if (!pendingPhotos.length) {
+ if (status) { status.textContent = 'Primero elige al menos una foto.'; status.className = 'ynk-hint ynk-warn'; }
+ return;
+ }
+ var fd = new FormData();
+ fd.append('file', pendingPhotos[0].file, pendingPhotos[0].file.name);
+ var nm = val('ynkName'); if (nm) fd.append('name', nm);
+ var cond = document.getElementById('ynkCondition').value; if (cond) fd.append('condition', cond);
+
+ if (btn) btn.disabled = true;
+ if (status) { status.textContent = 'Analizando la foto con IA…'; status.className = 'ynk-hint'; }
+
+ apiUpload(API + '/grade-suggest', fd)
+ .then(function (raw) {
+ var d = unwrap(raw) || {};
+ var g = d.condition_grade_suggested;
+ if (g) {
+ document.getElementById('ynkGrade').value = g;
+ var conf = (typeof d.confidence === 'number') ? ' (confianza ' + Math.round(d.confidence * 100) + '%)' : '';
+ if (status) {
+ status.textContent = 'Grado sugerido: ' + g + conf + (d.notes ? ' — ' + d.notes : '');
+ status.className = 'ynk-hint ynk-ok';
+ }
+ } else if (status) {
+ status.textContent = 'La IA no pudo sugerir un grado' + (d.error ? ': ' + d.error : '.');
+ status.className = 'ynk-hint ynk-warn';
+ }
+ })
+ .catch(function (err) {
+ if (status) { status.textContent = 'No se pudo sugerir grado: ' + errMsg(err); status.className = 'ynk-hint ynk-err'; }
+ })
+ .then(function () { if (btn) btn.disabled = false; });
+ }
+
+ // =====================================================================
+ // TAB 6 — MAYOREO B2B
+ // Flujo: publicas una pieza (vender) → otro negocio la compra (catálogo)
+ // → el vendedor confirma y marca enviado → el comprador marca recibido.
+ //
+ // Contract: /pos/api/b2b
+ // GET /my-listings GET /catalog GET /listings/
+ // POST /listings PATCH /listings/ DELETE /listings/
+ // POST /orders GET /orders GET /orders/incoming
+ // GET /orders/ PUT /orders//status
+ // GET /seller/status
+ // =====================================================================
+
+ var B2B = '/pos/api/b2b';
+
+ var b2bView = 'catalog'; // catalog | listings | orders
+ var b2bOrdersView = 'incoming'; // incoming (vendedor) | purchases (comprador)
+ var b2bInitDone = false;
+ var b2bSeller = null; // {is_seller, listings_count, incoming_orders}
+ var b2bCatalog = [];
+ var b2bListings = [];
+ var b2bOrders = [];
+ var b2bCatalogAbort = null;
+ var b2bBuyTarget = null; // listing being purchased
+ var b2bOrderDetail = null; // {id, side} of the open detail modal
+
+ function setDisplay(id, show) {
+ var el = document.getElementById(id);
+ if (el) el.style.display = show ? '' : 'none';
+ }
+
+ function b2bCounterparty(o, sellerSide) {
+ if (sellerSide) {
+ return o.buyer_name || o.buyer_tenant_name ||
+ (o.buyer_tenant_id ? '#' + o.buyer_tenant_id : 'Comprador');
+ }
+ return o.seller_name || o.seller_tenant_name ||
+ (o.seller_tenant_id ? '#' + o.seller_tenant_id : 'Vendedor');
+ }
+
+ function b2bSellerLabel(it) {
+ if (it.seller_name) return it.seller_name;
+ if (it.seller_tenant_id) return '#' + it.seller_tenant_id;
+ return '—';
+ }
+
+ var B2B_ORDER_STATUS = {
+ pending: { label: 'Pendiente', cls: 'badge--pending' },
+ new: { label: 'Nuevo', cls: 'badge--pending' },
+ paid: { label: 'Pagado', cls: 'badge--transit' },
+ processing: { label: 'En proceso', cls: 'badge--transit' },
+ confirmed: { label: 'Confirmado', cls: 'badge--transit' },
+ shipped: { label: 'Enviado', cls: 'badge--new' },
+ sent: { label: 'Enviado', cls: 'badge--new' },
+ received: { label: 'Recibido', cls: 'badge--ok' },
+ delivered: { label: 'Entregado', cls: 'badge--ok' },
+ completed: { label: 'Completado', cls: 'badge--ok' },
+ cancelled: { label: 'Cancelado', cls: 'badge--cancelled' },
+ canceled: { label: 'Cancelado', cls: 'badge--cancelled' }
+ };
+ function b2bStatusBadge(status) {
+ var s = (status || '').toLowerCase();
+ var meta = B2B_ORDER_STATUS[s] || { label: status || '—', cls: 'badge--pending' };
+ return '' + esc(meta.label) + '';
+ }
+
+ function ynkB2bInit() {
+ if (b2bInitDone) { ynkB2bView(b2bView); return; }
+ b2bInitDone = true;
+
+ var q = document.getElementById('ynkB2bQ');
+ if (q) q.addEventListener('input', debounce(function () { loadB2bCatalog(1); }, 350));
+ ['ynkB2bMake', 'ynkB2bModel', 'ynkB2bYear', 'ynkB2bGrade'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.addEventListener('change', function () { loadB2bCatalog(1); });
+ });
+
+ var pick = document.getElementById('ynkB2bPublishPiece');
+ if (pick) {
+ pick.addEventListener('change', function () {
+ var opt = this.options[this.selectedIndex];
+ var p = opt ? opt.getAttribute('data-price') : '';
+ var priceEl = document.getElementById('ynkB2bPublishPrice');
+ if (priceEl) priceEl.value = (p === null || p === undefined || p === '') ? '' : p;
+ });
+ }
+
+ loadB2bSellerStatus().then(refreshB2bBadge);
+ ynkB2bView(b2bView);
+ }
+
+ function ynkB2bView(view) {
+ b2bView = (view === 'listings' || view === 'orders') ? view : 'catalog';
+ var segs = { catalog: 'ynkB2bSegCatalog', listings: 'ynkB2bSegListings', orders: 'ynkB2bSegOrders' };
+ Object.keys(segs).forEach(function (k) {
+ var el = document.getElementById(segs[k]);
+ if (!el) return;
+ el.classList.toggle('is-active', k === b2bView);
+ el.setAttribute('aria-selected', k === b2bView ? 'true' : 'false');
+ });
+ setDisplay('ynkB2bCatalog', b2bView === 'catalog');
+ setDisplay('ynkB2bListings', b2bView === 'listings');
+ setDisplay('ynkB2bOrders', b2bView === 'orders');
+
+ if (b2bView === 'catalog') loadB2bCatalog(1);
+ else if (b2bView === 'listings') loadB2bListings();
+ else ynkB2bOrdersView(b2bOrdersView);
+ }
+
+ function ynkB2bRefresh() {
+ toast('Actualizando mayoreo…', 'info');
+ loadB2bSellerStatus().then(refreshB2bBadge);
+ if (b2bView === 'catalog') loadB2bCatalog(1);
+ else if (b2bView === 'listings') loadB2bListings();
+ else loadB2bOrders();
+ }
+
+ // ─── Vendedor / estado de la cuenta ──────────────────────────────────
+
+ function loadB2bSellerStatus() {
+ return apiFetch(B2B + '/seller/status')
+ .then(function (res) {
+ b2bSeller = unwrap(res) || null;
+ renderB2bSellerStatus();
+ refreshB2bBadge();
+ return b2bSeller;
+ })
+ .catch(function () {
+ b2bSeller = null;
+ renderB2bSellerStatus();
+ refreshB2bBadge();
+ return null;
+ });
+ }
+
+ function renderB2bSellerStatus() {
+ var box = document.getElementById('ynkB2bSellerStatus');
+ var btn = document.getElementById('ynkB2bPublishBtn');
+ if (!box) return;
+ if (!b2bSeller) { box.textContent = ''; return; }
+ var isSeller = !!b2bSeller.is_seller;
+ box.className = 'ynk-hint' + (isSeller ? ' ynk-ok' : ' ynk-warn');
+ box.textContent = isSeller
+ ? '✔ Habilitado para vender en mayoreo · ' + fmtInt(b2bSeller.listings_count) + ' publicación(es) · ' +
+ fmtInt(b2bSeller.incoming_orders) + ' pedido(s) recibido(s)'
+ : 'Tu negocio no está habilitado para vender en mayoreo (is_seller: false). Puedes comprar en el catálogo, ' +
+ 'pero para publicar piezas contacta al administrador.';
+ if (btn) {
+ btn.disabled = !isSeller;
+ btn.title = isSeller ? '' : 'Tu negocio no está habilitado para vender en mayoreo';
+ }
+ }
+
+ function refreshB2bBadge() {
+ var b = document.getElementById('badge-b2b');
+ if (!b) return;
+ b.textContent = b2bSeller ? fmtInt(b2bSeller.incoming_orders) : '0';
+ }
+
+ // ─── Catálogo (comprar) ──────────────────────────────────────────────
+
+ function loadB2bCatalog(page) {
+ var grid = document.getElementById('ynkB2bCatalogGrid');
+ if (!grid) return;
+ grid.innerHTML = 'Cargando catálogo…
';
+
+ var params = {
+ q: val('ynkB2bQ'),
+ make: val('ynkB2bMake'),
+ model: val('ynkB2bModel'),
+ year: val('ynkB2bYear'),
+ grade: val('ynkB2bGrade'),
+ page: page || 1,
+ per_page: 24
+ };
+
+ if (b2bCatalogAbort) b2bCatalogAbort.abort();
+ b2bCatalogAbort = new AbortController();
+
+ apiFetch(B2B + '/catalog?' + qs(params), { signal: b2bCatalogAbort.signal })
+ .then(function (res) {
+ b2bCatalogAbort = null;
+ if (!res) return;
+ b2bCatalog = asList(res);
+ var footer = document.getElementById('ynkB2bCatalogFooter');
+ var pager = document.getElementById('ynkB2bCatalogPagination');
+ if (!b2bCatalog.length) {
+ grid.innerHTML = 'Sin piezas en mayoreo con esos filtros.
';
+ if (footer) footer.textContent = '0 piezas';
+ if (pager) pager.innerHTML = '';
+ return;
+ }
+ grid.innerHTML = b2bCatalog.map(b2bCardHtml).join('');
+ var pg = res.pagination || {};
+ if (footer) footer.textContent = fmtInt(pg.total !== undefined ? pg.total : b2bCatalog.length) + ' piezas en mayoreo';
+ renderPagination(pager, pg, 'ynkB2bCatalogPage');
+ })
+ .catch(function (err) {
+ if (!err || err.name === 'AbortError') return;
+ b2bCatalogAbort = null;
+ grid.innerHTML = 'Error al cargar el catálogo: ' + esc(errMsg(err)) + '
';
+ toast('Error al cargar el catálogo de mayoreo: ' + errMsg(err), 'error');
+ });
+ }
+
+ function b2bCardHtml(it, i) {
+ var img = it.image_url
+ ? '
'
+ : 'sin foto
';
+ var veh = [it.donor_make, it.donor_model, it.donor_year].filter(Boolean).join(' ');
+ return '' + img +
+ '
' +
+ '
' + (esc(it.name) || 'Pieza sin nombre') + '
' +
+ '
' + gradeHtml(it.condition_grade) +
+ '' + (esc(it.part_number) || 'sin No. parte') + '
' +
+ (veh ? '
Donador: ' + esc(veh) + '
' : '') +
+ '
Vendedor: ' + esc(b2bSellerLabel(it)) + '
' +
+ '
' + fmtMoney(it.price_wholesale) + '
' +
+ '' +
+ '
';
+ }
+
+ window.ynkB2bImgFallback = function (img) {
+ var box = img && img.parentNode;
+ if (box) box.innerHTML = 'sin foto
';
+ };
+
+ function loadB2bCatalogUi() { loadB2bCatalog(1); }
+
+ function ynkB2bClearCatalogFilters() {
+ ['ynkB2bQ', 'ynkB2bMake', 'ynkB2bModel', 'ynkB2bYear', 'ynkB2bGrade'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.value = '';
+ });
+ loadB2bCatalog(1);
+ }
+
+ // ─── Comprar (crear orden) ───────────────────────────────────────────
+
+ function ynkB2bOpenBuy(i) {
+ var it = b2bCatalog[i];
+ if (!it) return;
+ b2bBuyTarget = it;
+ var title = document.getElementById('ynkB2bBuyTitle');
+ if (title) title.textContent = 'Comprar: ' + (it.name || 'pieza');
+ var info = document.getElementById('ynkB2bBuyInfo');
+ if (info) {
+ var veh = [it.donor_make, it.donor_model, it.donor_year].filter(Boolean).join(' ');
+ info.innerHTML = gradeHtml(it.condition_grade) + ' ' + (esc(it.name) || '—') + '' +
+ (it.part_number ? ' · ' + esc(it.part_number) : '') +
+ (veh ? '
Donador: ' + esc(veh) : '') +
+ '
Vendedor: ' + esc(b2bSellerLabel(it)) +
+ ' · Mayoreo: ' + fmtMoney(it.price_wholesale) + ' c/u';
+ }
+ var qty = document.getElementById('ynkB2bBuyQty');
+ if (qty) qty.value = '1';
+ var notes = document.getElementById('ynkB2bBuyNotes');
+ if (notes) notes.value = '';
+ var res = document.getElementById('ynkB2bBuyResult');
+ if (res) { res.className = 'ynk-save-result'; res.innerHTML = ''; }
+ ynkB2bUpdateBuyTotal();
+ openModal('ynkB2bBuyModal');
+ }
+
+ function ynkB2bUpdateBuyTotal() {
+ var el = document.getElementById('ynkB2bBuyTotal');
+ if (!el) return;
+ var qty = intOrNull('ynkB2bBuyQty') || 0;
+ var price = b2bBuyTarget ? num(b2bBuyTarget.price_wholesale) : 0;
+ el.textContent = fmtMoney(qty * price);
+ }
+
+ function ynkB2bSubmitOrder() {
+ if (!b2bBuyTarget) return;
+ var res = document.getElementById('ynkB2bBuyResult');
+ var btn = document.getElementById('ynkB2bBuyBtn');
+ var qty = intOrNull('ynkB2bBuyQty');
+ if (!qty || qty < 1) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'La cantidad debe ser al menos 1.'; }
+ return;
+ }
+ var body = { items: [{ listing_id: b2bBuyTarget.id, quantity: qty }] };
+ var notes = val('ynkB2bBuyNotes');
+ if (notes) body.notes = notes;
+
+ if (btn) { btn.disabled = true; btn.textContent = 'Creando orden…'; }
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Creando la orden…'; }
+
+ apiFetch(B2B + '/orders', { method: 'POST', body: JSON.stringify(body) })
+ .then(function (raw) {
+ var o = unwrap(raw) || {};
+ var total = (o.total !== undefined && o.total !== null)
+ ? fmtMoney(o.total)
+ : fmtMoney(qty * num(b2bBuyTarget.price_wholesale));
+ if (res) {
+ res.className = 'ynk-save-result ynk-ok';
+ res.innerHTML = '✔ Orden #' + esc(o.id !== undefined ? o.id : '—') + ' creada — total ' + total + '' +
+ (o.status ? ' · estado: ' + b2bStatusBadge(o.status) : '') +
+ '
El vendedor la verá en “Pedidos › Recibidos”.';
+ }
+ toast('Orden #' + (o.id !== undefined ? o.id : '') + ' creada por ' + total, 'ok');
+ loadB2bSellerStatus();
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo crear la orden: ' + errMsg(err); }
+ toast('Error al crear la orden: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) { btn.disabled = false; btn.textContent = 'Crear orden'; } });
+ }
+
+ // ─── Mis publicaciones (vender) ──────────────────────────────────────
+
+ function loadB2bListings() {
+ var body = document.getElementById('ynkB2bListingsBody');
+ if (!body) return;
+ body.innerHTML = emptyRow(9, 'Cargando publicaciones…');
+ loadB2bSellerStatus();
+ apiFetch(B2B + '/my-listings')
+ .then(function (res) {
+ b2bListings = asList(res);
+ renderB2bListings();
+ })
+ .catch(function (err) {
+ body.innerHTML = emptyRow(9, 'Error al cargar publicaciones: ' + errMsg(err));
+ toast('Error al cargar tus publicaciones: ' + errMsg(err), 'error');
+ });
+ }
+
+ function renderB2bListings() {
+ var body = document.getElementById('ynkB2bListingsBody');
+ if (!body) return;
+ var footer = document.getElementById('ynkB2bListingsFooter');
+ if (!b2bListings.length) {
+ body.innerHTML = emptyRow(9, 'Aún no publicas piezas en mayoreo. Usa “Publicar pieza en mayoreo”.');
+ if (footer) footer.textContent = '0 publicaciones';
+ return;
+ }
+ body.innerHTML = b2bListings.map(b2bListingRow).join('');
+ if (footer) footer.textContent = b2bListings.length + ' publicación(es)';
+ }
+
+ function b2bListingRow(it) {
+ var veh = [it.donor_make, it.donor_model, it.donor_year].filter(Boolean).join(' ');
+ var st = (it.status || '').toLowerCase();
+ var cls = (st === 'active' || st === 'published' || st === 'available') ? 'badge--ok'
+ : (st === 'sold' ? 'badge--complete'
+ : (st === 'paused' || st === 'pending' ? 'badge--pending' : 'badge--cancelled'));
+ var img = it.image_url
+ ? '
'
+ : 'sin
foto
';
+ var price = (it.price_wholesale === undefined || it.price_wholesale === null) ? '' : esc(it.price_wholesale);
+ return '' +
+ '| ' + img + ' | ' +
+ '' + (esc(it.part_number) || '—') + ' | ' +
+ '' + (esc(it.name) || '—') + ' | ' +
+ '' + gradeHtml(it.condition_grade) + ' | ' +
+ '' + (esc(veh) || '—') + ' | ' +
+ '' +
+ ' ' +
+ '' +
+ ' | ' +
+ '' + esc(st || '—') + ' | ' +
+ '' + (esc(String(it.created_at || '').substring(0, 10)) || '—') + ' | ' +
+ '' +
+ '' +
+ ' |
';
+ }
+
+ function ynkB2bSavePrice(id) {
+ var el = document.getElementById('ynkB2bPrice' + id);
+ if (!el) return;
+ var price = parseFloat(el.value);
+ if (isNaN(price) || price < 0) { toast('Precio mayoreo inválido', 'warn'); return; }
+ apiFetch(B2B + '/listings/' + id, { method: 'PATCH', body: JSON.stringify({ price_wholesale: price }) })
+ .then(function () {
+ toast('Precio mayoreo actualizado: ' + fmtMoney(price), 'ok');
+ loadB2bListings();
+ })
+ .catch(function (err) {
+ toast('No se pudo actualizar el precio: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkB2bRetire(id) {
+ if (!window.confirm('¿Retirar esta pieza del mayoreo? Los compradores dejarán de verla.')) return;
+ apiFetch(B2B + '/listings/' + id, { method: 'DELETE' })
+ .then(function () {
+ toast('Publicación retirada', 'ok');
+ loadB2bListings();
+ loadB2bSellerStatus();
+ })
+ .catch(function (err) {
+ toast('No se pudo retirar la publicación: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkB2bOpenPublish() {
+ if (b2bSeller && !b2bSeller.is_seller) {
+ toast('Tu negocio no está habilitado para vender en mayoreo.', 'warn');
+ return;
+ }
+ var sel = document.getElementById('ynkB2bPublishPiece');
+ if (sel) sel.innerHTML = '';
+ var price = document.getElementById('ynkB2bPublishPrice');
+ if (price) price.value = '';
+ var notes = document.getElementById('ynkB2bPublishNotes');
+ if (notes) notes.value = '';
+ var res = document.getElementById('ynkB2bPublishResult');
+ if (res) { res.className = 'ynk-save-result'; res.innerHTML = ''; }
+ openModal('ynkB2bPublishModal');
+ loadB2bInventoryOptions();
+ }
+
+ function loadB2bInventoryOptions() {
+ var sel = document.getElementById('ynkB2bPublishPiece');
+ if (!sel) return;
+ apiFetch(API + '/pieces?per_page=200')
+ .then(function (res) {
+ var items = asList(res);
+ if (!items.length) {
+ sel.innerHTML = '';
+ return;
+ }
+ sel.innerHTML = '' + items.map(function (p) {
+ var label = '#' + p.id + ' · ' + (p.name || 'pieza') + (p.part_number ? ' (' + p.part_number + ')' : '');
+ return '';
+ }).join('');
+ })
+ .catch(function (err) {
+ sel.innerHTML = '';
+ toast('No se pudieron cargar las piezas del inventario: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkB2bSubmitPublish() {
+ var sel = document.getElementById('ynkB2bPublishPiece');
+ var invId = sel ? sel.value : '';
+ var price = numOrNull('ynkB2bPublishPrice');
+ var res = document.getElementById('ynkB2bPublishResult');
+ var btn = document.getElementById('ynkB2bPublishSubmitBtn');
+
+ if (!invId) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'Selecciona una pieza del inventario.'; }
+ return;
+ }
+ if (price === null || price < 0) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'Indica un precio de mayoreo válido.'; }
+ return;
+ }
+ var body = { inventory_id: parseInt(invId, 10), price_wholesale: price };
+ var notes = val('ynkB2bPublishNotes');
+ if (notes) body.notes = notes;
+
+ if (btn) { btn.disabled = true; btn.textContent = 'Publicando…'; }
+ if (res) { res.className = 'ynk-save-result'; res.textContent = 'Publicando…'; }
+
+ apiFetch(B2B + '/listings', { method: 'POST', body: JSON.stringify(body) })
+ .then(function () {
+ toast('Pieza publicada en mayoreo', 'ok');
+ ynkCloseModal('ynkB2bPublishModal');
+ ynkB2bView('listings');
+ })
+ .catch(function (err) {
+ if (res) { res.className = 'ynk-save-result ynk-err'; res.textContent = 'No se pudo publicar: ' + errMsg(err); }
+ toast('Error al publicar en mayoreo: ' + errMsg(err), 'error');
+ })
+ .then(function () { if (btn) { btn.disabled = false; btn.textContent = 'Publicar'; } });
+ }
+
+ // ─── Pedidos (vendedor y comprador) ──────────────────────────────────
+
+ function ynkB2bOrdersView(view) {
+ b2bOrdersView = view === 'purchases' ? 'purchases' : 'incoming';
+ var inc = document.getElementById('ynkB2bSegIncoming');
+ var pur = document.getElementById('ynkB2bSegPurchases');
+ if (inc) { inc.classList.toggle('is-active', b2bOrdersView === 'incoming'); inc.setAttribute('aria-selected', b2bOrdersView === 'incoming' ? 'true' : 'false'); }
+ if (pur) { pur.classList.toggle('is-active', b2bOrdersView === 'purchases'); pur.setAttribute('aria-selected', b2bOrdersView === 'purchases' ? 'true' : 'false'); }
+
+ var sellerSide = b2bOrdersView === 'incoming';
+ var head = document.getElementById('ynkB2bOrdersCounterpartyHead');
+ if (head) head.textContent = sellerSide ? 'Comprador' : 'Vendedor';
+ var hint = document.getElementById('ynkB2bOrdersHint');
+ if (hint) hint.textContent = sellerSide
+ ? 'Pedidos que otros negocios te hacen.'
+ : 'Pedidos que hiciste a otros negocios.';
+
+ loadB2bOrders();
+ }
+
+ function loadB2bOrders() {
+ var body = document.getElementById('ynkB2bOrdersBody');
+ if (!body) return;
+ body.innerHTML = emptyRow(7, 'Cargando pedidos…');
+ var sellerSide = b2bOrdersView === 'incoming';
+ apiFetch(B2B + (sellerSide ? '/orders/incoming' : '/orders'))
+ .then(function (res) {
+ b2bOrders = asList(res);
+ renderB2bOrders();
+ })
+ .catch(function (err) {
+ body.innerHTML = emptyRow(7, 'Error al cargar pedidos: ' + errMsg(err));
+ toast('Error al cargar los pedidos de mayoreo: ' + errMsg(err), 'error');
+ });
+ }
+
+ function renderB2bOrders() {
+ var body = document.getElementById('ynkB2bOrdersBody');
+ if (!body) return;
+ var sellerSide = b2bOrdersView === 'incoming';
+ var footer = document.getElementById('ynkB2bOrdersFooter');
+ if (!b2bOrders.length) {
+ body.innerHTML = emptyRow(7, sellerSide
+ ? 'Todavía no has recibido pedidos de mayoreo.'
+ : 'Todavía no has hecho compras de mayoreo.');
+ if (footer) footer.textContent = '0 pedidos';
+ return;
+ }
+ body.innerHTML = b2bOrders.map(function (o) {
+ var items = b2bItems(o);
+ var count = o.items_count !== undefined ? o.items_count : items.length;
+ return '' +
+ '| ' + esc(o.id) + ' | ' +
+ '' + esc(b2bCounterparty(o, sellerSide)) + ' | ' +
+ '' + fmtInt(count) + ' | ' +
+ '' + fmtMoney(o.total) + ' | ' +
+ '' + b2bStatusBadge(o.status) + ' | ' +
+ '' + (esc(String(o.created_at || '').substring(0, 10)) || '—') + ' | ' +
+ '' + b2bOrderActions(o, sellerSide, true) + ' | ' +
+ '
';
+ }).join('');
+ if (footer) footer.textContent = b2bOrders.length + ' pedido(s)';
+ }
+
+ function b2bItems(o) {
+ var arr = o.items || o.order_items || o.lines || [];
+ return Array.isArray(arr) ? arr : [];
+ }
+
+ // Action buttons depend on the order status and which side we are on.
+ function b2bOrderActions(o, sellerSide, includeDetail) {
+ var s = (o.status || '').toLowerCase();
+ var id = o.id;
+ var btns = [];
+ if (sellerSide) {
+ if (s === 'pending' || s === 'new') {
+ btns.push('');
+ }
+ if (s === 'pending' || s === 'new' || s === 'confirmed' || s === 'paid' || s === 'processing') {
+ btns.push('');
+ btns.push('');
+ }
+ } else {
+ if (s === 'shipped' || s === 'sent' || s === 'confirmed' || s === 'processing') {
+ btns.push('');
+ }
+ if (s === 'pending' || s === 'new' || s === 'confirmed') {
+ btns.push('');
+ }
+ }
+ if (includeDetail) {
+ btns.push('');
+ }
+ return btns.join(' ');
+ }
+
+ function ynkB2bSetStatus(id, status) {
+ var labels = {
+ confirmed: 'confirmado', shipped: 'marcado como enviado',
+ received: 'marcado como recibido', cancelled: 'cancelado'
+ };
+ if (status === 'cancelled' && !window.confirm('¿Cancelar el pedido #' + id + '?')) return;
+ apiFetch(B2B + '/orders/' + id + '/status', { method: 'PUT', body: JSON.stringify({ status: status }) })
+ .then(function () {
+ toast('Pedido #' + id + ' ' + (labels[status] || status), 'ok');
+ loadB2bOrders();
+ loadB2bSellerStatus();
+ if (b2bOrderDetail && b2bOrderDetail.id === id) {
+ ynkB2bOpenOrder(id, b2bOrderDetail.side);
+ }
+ })
+ .catch(function (err) {
+ toast('No se pudo actualizar el pedido: ' + errMsg(err), 'error');
+ });
+ }
+
+ function ynkB2bOpenOrder(id, side) {
+ b2bOrderDetail = { id: id, side: side ? 1 : 0 };
+ var title = document.getElementById('ynkB2bOrderTitle');
+ if (title) title.textContent = 'Pedido #' + id;
+ var body = document.getElementById('ynkB2bOrderBody');
+ if (body) body.innerHTML = 'Cargando pedido…';
+ var actions = document.getElementById('ynkB2bOrderActions');
+ if (actions) actions.innerHTML = '';
+ openModal('ynkB2bOrderModal');
+
+ apiFetch(B2B + '/orders/' + id)
+ .then(function (raw) {
+ var o = unwrap(raw) || {};
+ renderB2bOrderDetail(o, id, side);
+ })
+ .catch(function (err) {
+ if (body) body.innerHTML = 'No se pudo cargar el pedido: ' + esc(errMsg(err)) + '';
+ toast('Error al cargar el pedido: ' + errMsg(err), 'error');
+ });
+ }
+
+ function renderB2bOrderDetail(o, id, side) {
+ var sellerSide = !!side;
+ var items = b2bItems(o);
+ var body = document.getElementById('ynkB2bOrderBody');
+ var actions = document.getElementById('ynkB2bOrderActions');
+
+ var html = 'Pedido #' + esc(o.id !== undefined ? o.id : id) + '
' +
+ '
Estado' + b2bStatusBadge(o.status) + '
' +
+ '
' + (sellerSide ? 'Comprador' : 'Vendedor') + '' +
+ esc(b2bCounterparty(o, sellerSide)) + '
' +
+ '
Total' + fmtMoney(o.total) + '
' +
+ (o.created_at ? '
Creado' +
+ esc(String(o.created_at).substring(0, 19).replace('T', ' ')) + '
' : '') +
+ (o.notes ? '
Notas' + esc(o.notes) + '
' : '') +
+ '
';
+
+ if (items.length) {
+ html += '' +
+ '| Pieza | No. Parte | Cantidad | ' +
+ 'P. unitario | Subtotal | ' +
+ '
' + items.map(function (it) {
+ var qty = it.quantity !== undefined ? it.quantity : (it.qty !== undefined ? it.qty : 1);
+ var unit = it.unit_price !== undefined ? it.unit_price
+ : (it.price_wholesale !== undefined ? it.price_wholesale : it.price);
+ var sub = it.subtotal !== undefined ? it.subtotal : (num(unit) * num(qty));
+ return '' +
+ '| ' + (esc(it.name) || ('Pieza #' + esc(it.listing_id !== undefined ? it.listing_id : (it.inventory_id || '—')))) + ' | ' +
+ '' + (esc(it.part_number) || '—') + ' | ' +
+ '' + fmtInt(qty) + ' | ' +
+ '' + fmtMoney(unit) + ' | ' +
+ '' + fmtMoney(sub) + ' | ' +
+ '
';
+ }).join('') + '
';
+ } else {
+ html += 'El detalle de piezas no está disponible.
';
+ }
+
+ if (body) body.innerHTML = html;
+ if (actions) actions.innerHTML = b2bOrderActions(o, sellerSide, false);
+ }
+
+ // =====================================================================
+ // GLOBAL EXPORTS (used by inline onclick handlers in yonke.html)
+ // =====================================================================
+
+ window.switchTab = switchTab;
+ window.ynkRefreshPieces = function () { refreshSummary(); loadPieces(1); toast('Lista actualizada', 'info'); };
+ window.ynkPiecesPage = function (p) { loadPieces(p); };
+ window.ynkResetPieceFilters = function () {
+ document.getElementById('ynkQ').value = '';
+ document.getElementById('ynkFiltCondition').value = '';
+ document.getElementById('ynkFiltGrade').value = '';
+ document.getElementById('ynkFiltOwnership').value = '';
+ loadPieces(1);
+ };
+ window.ynkDecodeVin = ynkDecodeVin;
+ window.ynkRemovePhoto = ynkRemovePhoto;
+ window.ynkSubmitPiece = ynkSubmitPiece;
+ window.ynkResetPieceForm = function () { resetPieceForm(); stopIntakeTimer(); startIntakeTimer(); };
+ window.ynkSetDonorView = ynkSetDonorView;
+ window.ynkSearchDonors = function (p) { loadDonors(p || 1); };
+ window.ynkDonorsPage = function (p) { loadDonors(p); };
+ window.ynkDonorToPieces = ynkDonorToPieces;
+ window.ynkSearchDonorPieces = function (p) { loadDonorPieces(p || 1); };
+ window.ynkDonorPiecesPage = function (p) { loadDonorPieces(p); };
+ window.ynkClearDonorPieceFilters = function () {
+ ['ynkDonorMake', 'ynkDonorModel', 'ynkDonorYear', 'ynkDonorVin', 'ynkDonorPieceQ']
+ .forEach(function (i) { var el = document.getElementById(i); if (el) el.value = ''; });
+ loadDonorPieces(1);
+ };
+ window.ynkLoadConsignments = loadConsignments;
+ window.ynkOpenConsignmentModal = ynkOpenConsignmentModal;
+ window.ynkCreateConsignment = ynkCreateConsignment;
+ window.ynkOpenConsignment = ynkOpenConsignment;
+ window.ynkAddConsignmentItem = ynkAddConsignmentItem;
+ window.ynkSettleConsignment = ynkSettleConsignment;
+ window.ynkOpenEditPiece = ynkOpenEditPiece;
+ window.ynkOpenPieceFromSearch = ynkOpenPieceFromSearch;
+ window.ynkSaveEditPiece = ynkSaveEditPiece;
+ window.ynkOpenImages = ynkOpenImages;
+ window.ynkDeleteImage = ynkDeleteImage;
+ // Piece detail / actions
+ window.ynkOpenPiece = ynkOpenPiece;
+ window.ynkOpenLabel = ynkOpenLabel;
+ window.ynkPublishMeli = ynkPublishMeli;
+ window.ynkSuggestPrice = ynkSuggestPrice;
+ window.ynkApplySuggestedPrice = ynkApplySuggestedPrice;
+ window.ynkApplyIdentified = ynkApplyIdentified;
+ window.ynkAddCompat = ynkAddCompat;
+ window.ynkEditFromDetail = ynkEditFromDetail;
+ // Intake modes
+ window.ynkSetIntakeMode = ynkSetIntakeMode;
+ window.ynkFullAddRow = ynkFullAddRow;
+ window.ynkFullRemoveRow = ynkFullRemoveRow;
+ window.ynkFullClearRows = ynkFullClearRows;
+ window.ynkFullResetForm = ynkFullResetForm;
+ window.ynkFullDecodeVin = ynkFullDecodeVin;
+ window.ynkSubmitDonorFull = ynkSubmitDonorFull;
+ window.ynkSuggestGradeFromPhoto = ynkSuggestGradeFromPhoto;
+ // Consignment statement
+ window.ynkOpenStatement = ynkOpenStatement;
+ window.ynkLoadStatement = ynkLoadStatement;
+ window.ynkPrintStatement = ynkPrintStatement;
+ // Reports
+ window.ynkSetReportView = ynkSetReportView;
+ window.ynkLoadAging = ynkLoadAging;
+ window.ynkLoadReportSummary = ynkLoadReportSummary;
+ window.ynkLoadDuplicates = ynkLoadDuplicates;
+ // Mayoreo B2B
+ window.ynkB2bView = ynkB2bView;
+ window.ynkB2bRefresh = ynkB2bRefresh;
+ window.ynkB2bOrdersView = ynkB2bOrdersView;
+ window.ynkB2bOpenBuy = ynkB2bOpenBuy;
+ window.ynkB2bUpdateBuyTotal = ynkB2bUpdateBuyTotal;
+ window.ynkB2bSubmitOrder = ynkB2bSubmitOrder;
+ window.ynkB2bOpenPublish = ynkB2bOpenPublish;
+ window.ynkB2bSubmitPublish = ynkB2bSubmitPublish;
+ window.ynkB2bSavePrice = ynkB2bSavePrice;
+ window.ynkB2bRetire = ynkB2bRetire;
+ window.ynkB2bSetStatus = ynkB2bSetStatus;
+ window.ynkB2bOpenOrder = ynkB2bOpenOrder;
+ window.ynkB2bCatalogPage = function (p) { loadB2bCatalog(p); };
+ window.ynkB2bClearCatalogFilters = ynkB2bClearCatalogFilters;
+ window.loadB2bCatalogUi = loadB2bCatalogUi;
+ // CSV import
+ window.ynkOpenImportModal = ynkOpenImportModal;
+ window.ynkSubmitImport = ynkSubmitImport;
+ window.ynkCloseModal = function (id) {
+ var el = document.getElementById(id);
+ if (el) el.classList.remove('is-open');
+ };
+
+ // =====================================================================
+ // INIT
+ // =====================================================================
+
+ function debounce(fn, ms) {
+ var t = null;
+ return function () {
+ clearTimeout(t);
+ t = setTimeout(fn, ms);
+ };
+ }
+
+ function init() {
+ // Overlay click closes modals
+ document.querySelectorAll('.inv-modal-overlay').forEach(function (ov) {
+ ov.addEventListener('click', function (e) {
+ if (e.target === ov) ov.classList.remove('is-open');
+ });
+ });
+
+ // Tab 1 — filters
+ var q = document.getElementById('ynkQ');
+ if (q) q.addEventListener('input', debounce(function () { loadPieces(1); }, 350));
+ ['ynkFiltCondition', 'ynkFiltGrade', 'ynkFiltOwnership'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.addEventListener('change', function () { loadPieces(1); });
+ });
+ var perPage = document.getElementById('ynkFiltPerPage');
+ if (perPage) perPage.addEventListener('change', function () { loadPieces(1); });
+
+ // Tab 2 — intake
+ var photos = document.getElementById('ynkPhotos');
+ if (photos) {
+ photos.addEventListener('change', function () {
+ addPhotos(this.files);
+ this.value = '';
+ });
+ }
+ var ownership = document.getElementById('ynkOwnership');
+ if (ownership) {
+ ownership.addEventListener('change', function () {
+ document.getElementById('ynkConsignmentWrap').style.display = this.value === 'consigna' ? '' : 'none';
+ });
+ }
+ var vin = document.getElementById('ynkVin');
+ if (vin) {
+ vin.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); ynkDecodeVin(); }
+ });
+ }
+ var panelNueva = document.getElementById('panel-nueva');
+ if (panelNueva) {
+ panelNueva.addEventListener('keydown', function (e) {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
+ e.preventDefault();
+ if (intakeMode === 'full') ynkSubmitDonorFull();
+ else ynkSubmitPiece();
+ }
+ });
+ }
+
+ // Tab 2b — full donor form
+ var fullOwnership = document.getElementById('ynkFullOwnership');
+ if (fullOwnership) {
+ fullOwnership.addEventListener('change', function () {
+ document.getElementById('ynkFullConsignmentWrap').style.display = this.value === 'consigna' ? '' : 'none';
+ });
+ }
+ var fullVin = document.getElementById('ynkFullVin');
+ if (fullVin) {
+ fullVin.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); ynkFullDecodeVin(); }
+ });
+ }
+
+ // Piece detail — photo-driven tools
+ var idInput = document.getElementById('ynkIdentifyInput');
+ if (idInput) {
+ idInput.addEventListener('change', function () {
+ if (this.files && this.files[0]) ynkIdentifyFile(this.files[0]);
+ this.value = '';
+ });
+ }
+ var valInput = document.getElementById('ynkValidateInput');
+ if (valInput) {
+ valInput.addEventListener('change', function () {
+ if (this.files && this.files[0]) ynkValidatePhoto(this.files[0]);
+ this.value = '';
+ });
+ }
+
+ // CSV import picker
+ var importFile = document.getElementById('ynkImportFile');
+ if (importFile) {
+ importFile.addEventListener('change', function () {
+ var n = document.getElementById('ynkImportFileName');
+ if (n) n.textContent = (this.files && this.files[0]) ? this.files[0].name : 'Sin archivo seleccionado';
+ });
+ }
+
+ // Tab 3 — donors
+ var dq = document.getElementById('ynkDonorQ');
+ if (dq) dq.addEventListener('input', debounce(function () { loadDonors(1); }, 350));
+ var dpq = document.getElementById('ynkDonorPieceQ');
+ if (dpq) dpq.addEventListener('input', debounce(function () { loadDonorPieces(1); }, 350));
+
+ // Tab 4 — consignments
+ var cq = document.getElementById('ynkConsignQ');
+ if (cq) cq.addEventListener('input', debounce(renderConsignments, 200));
+
+ // Images modal upload
+ var imgInput = document.getElementById('ynkImagesInput');
+ if (imgInput) {
+ imgInput.addEventListener('change', function () {
+ if (!imagesTargetId) return;
+ var fn = ynkUploadImagesTo(imagesTargetId);
+ fn(this.files);
+ this.value = '';
+ });
+ }
+
+ // Hash deep links (#donador, #nueva, ...)
+ var hash = (window.location.hash || '').replace('#', '');
+ switchTab(TABS[hash] ? hash : 'piezas');
+ refreshSummary();
+
+ window.addEventListener('hashchange', function () {
+ var h = (window.location.hash || '').replace('#', '');
+ if (TABS[h] && h !== activeTab) switchTab(h);
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/pos/static/pwa/sw.js b/pos/static/pwa/sw.js
index 717e45e..fbe88dc 100644
--- a/pos/static/pwa/sw.js
+++ b/pos/static/pwa/sw.js
@@ -6,7 +6,7 @@
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
-const VERSION = 54;
+const VERSION = 61;
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const APP_SHELL = [
@@ -104,12 +104,6 @@ self.addEventListener('activate', function (event) {
);
}).then(function () {
return self.clients.claim();
- }).then(function () {
- return self.clients.matchAll({ type: 'window' }).then(function (clients) {
- clients.forEach(function (client) {
- client.postMessage({ type: 'SW_UPDATED', cacheName: CACHE_NAME });
- });
- });
})
);
});
@@ -128,7 +122,9 @@ self.addEventListener('fetch', function (event) {
// catalog.js?v=5 and catalog.js?v=6 share the same cache entry.
var cacheKey = req;
if (/\.(js|css|png|jpg|jpeg|webp|svg|gif|ico|woff|woff2|ttf|eot|json)$/.test(url.pathname)) {
- cacheKey = new Request(url.pathname);
+ // Si el template pide una version ({{?v=N}}), usar la URL completa como clave
+ // para que un bump de version SI fuerce la descarga del asset nuevo.
+ cacheKey = url.search ? req : new Request(url.pathname);
}
// Never cache auth endpoints
@@ -142,8 +138,8 @@ self.addEventListener('fetch', function (event) {
return;
}
- // Offline cart queue: POST /pos/api/cart/*
- if (req.method === 'POST' && url.pathname.indexOf('/pos/api/cart/') !== -1) {
+ // Offline queue: POST /pos/api/sales (venta offline) y /pos/api/cart/*
+ if (req.method === 'POST' && (url.pathname.indexOf('/pos/api/cart/') !== -1 || url.pathname === '/pos/api/sales')) {
event.respondWith(
fetch(req.clone()).then(function (response) {
return response;
diff --git a/pos/tasks.py b/pos/tasks.py
index 28d4ecc..4de42b8 100644
--- a/pos/tasks.py
+++ b/pos/tasks.py
@@ -102,14 +102,24 @@ def generate_report_task(self, report_type, params, tenant_id):
@celery.task(bind=True, max_retries=3)
def sync_meli_stock_price_task(self, tenant_id: int):
- """Sync stock and price for all active ML listings."""
+ """Sync stock and price for all active ML listings.
+
+ Opt-in per tenant via tenant_config.meli_auto_sync_stock_price. Without it
+ the task is a no-op: pushing the local POS price/quantity to ML zeroes and
+ pauses any listing whose local product has no stock, which destroys sales
+ for tenants whose inventory is not (yet) loaded. Enable the flag only once
+ the tenant's stock is trustworthy.
+ """
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
- svc = meli_svc._get_meli_service(cfg)
+ if not cfg.get('meli_auto_sync_stock_price'):
+ return {'skipped': True, 'reason': 'meli_auto_sync_stock_price disabled'}
+ # Pass conn so rotated OAuth tokens are persisted back to tenant_config
+ svc = meli_svc._get_meli_service(cfg, conn)
if not svc:
return {'error': 'MercadoLibre not configured'}
@@ -129,6 +139,7 @@ def sync_meli_stock_price_task(self, tenant_id: int):
updated = 0
failed = 0
+ skipped = 0
for row in listings:
listing_id, inv_id, ext_id, last_price = row
cur = conn.cursor()
@@ -138,10 +149,18 @@ def sync_meli_stock_price_task(self, tenant_id: int):
current_price = float(price_row[0]) if price_row and price_row[0] else 0
current_stock = stock_map.get(inv_id, 0)
+ # Never push price <= 0: ML rejects it and it would blank out the
+ # live publication. This also covers listings not linked to any
+ # local inventory (inventory_id NULL), where there is no reliable
+ # stock either — skip them instead of pushing quantity 0.
+ if current_price <= 0:
+ skipped += 1
+ continue
+
try:
svc.update_item(
ext_id,
- {"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
+ {"price": round(current_price, 2), "available_quantity": max(int(current_stock or 0), 0)},
)
cur = conn.cursor()
cur.execute(
@@ -166,7 +185,20 @@ def sync_meli_stock_price_task(self, tenant_id: int):
cur.close()
failed += 1
- return {'updated': updated, 'failed': failed, 'total': len(listings)}
+ # Drain the stock-sync queue so pause/reactivate-by-stock takes effect.
+ # record_operation() enqueues here whenever a listed product's stock
+ # moves; process_meli_sync_queue pauses items at 0 stock and reactivates
+ # them once stock returns. Best-effort: never fail the main sync.
+ queued = 0
+ try:
+ qres = meli_svc.process_meli_sync_queue(conn, batch_size=100)
+ if isinstance(qres, dict):
+ queued = qres.get('processed', 0)
+ except Exception:
+ pass
+
+ return {'updated': updated, 'failed': failed, 'skipped': skipped,
+ 'total': len(listings), 'queued': queued}
finally:
conn.close()
@@ -202,32 +234,31 @@ def sync_meli_orders_task(self, tenant_id: int):
@celery.task(bind=True, max_retries=3)
def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str):
- """Process incoming MercadoLibre webhook asynchronously."""
+ """Process a single MercadoLibre webhook notification.
+
+ Handles the concrete resource notified by ML (order / question / item)
+ instead of triggering a full sync.
+ """
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
+ resource_id = (resource or "").rstrip("/").split("/")[-1]
+ if not resource_id:
+ return {'ok': True, 'topic': topic, 'skipped': 'no resource'}
+
if topic.startswith("orders"):
- # Fetch full order and upsert
- cfg = meli_svc.get_meli_config(conn)
- svc = meli_svc._get_meli_service(cfg)
- if svc and resource:
- order_id = resource.split("/")[-1]
- full = svc.get_order(order_id)
- # Re-use fetch_and_save_orders by passing the order directly
- # For simplicity, trigger a full sync for recent orders
- return meli_svc.fetch_and_save_orders(conn)
+ result = meli_svc.fetch_and_save_order(conn, resource_id)
+ return {'ok': True, 'topic': topic, **result}
if topic.startswith("questions"):
- # Fetch single question and upsert locally
- if resource:
- question_id = resource.split("/")[-1]
- try:
- meli_svc.fetch_question_from_ml(conn, question_id)
- return {'ok': True, 'topic': topic, 'question_id': question_id}
- except Exception as qe:
- return {'ok': False, 'topic': topic, 'error': str(qe)}
+ meli_svc.fetch_question_from_ml(conn, resource_id)
+ return {'ok': True, 'topic': topic, 'question_id': resource_id}
+
+ if topic.startswith("items"):
+ result = meli_svc.sync_listing_from_meli(conn, resource_id)
+ return {'ok': True, 'topic': topic, **result}
return {'ok': True, 'topic': topic}
except Exception as e:
@@ -281,8 +312,13 @@ def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name,
@celery.task(bind=True)
-def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None, base_url: str = None):
- """Publish inventory items to MercadoLibre asynchronously."""
+def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None, base_url: str = None, chunk_size: int = 10, use_llm_description: bool = False):
+ """Publish inventory items to MercadoLibre asynchronously.
+
+ Publishes in chunks of `chunk_size` with a short pause between them to
+ stay clear of ML's rate limit (429) instead of firing the whole batch at
+ once. Set chunk_size=0 to disable pacing.
+ """
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
@@ -296,6 +332,8 @@ def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_
shipping_mode=shipping_mode,
custom_data=custom_data or {},
base_url=base_url,
+ use_llm_description=use_llm_description,
+ chunk_size=chunk_size,
)
return result
except Exception as e:
diff --git a/pos/templates/accounting.html b/pos/templates/accounting.html
index 4c2d145..a0fbc02 100644
--- a/pos/templates/accounting.html
+++ b/pos/templates/accounting.html
@@ -493,7 +493,7 @@
-
+
diff --git a/pos/templates/catalog.html b/pos/templates/catalog.html
index b3f2715..7e9789e 100644
--- a/pos/templates/catalog.html
+++ b/pos/templates/catalog.html
@@ -316,7 +316,7 @@
-
+
diff --git a/pos/templates/config.html b/pos/templates/config.html
index a8c0a37..7f7c0d3 100644
--- a/pos/templates/config.html
+++ b/pos/templates/config.html
@@ -367,6 +367,10 @@