Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
@@ -44,7 +44,7 @@
|
||||
var roleLabels = {
|
||||
'owner': _t('role_owner'), 'admin': _t('role_admin'), 'cashier': _t('role_cashier'),
|
||||
'warehouse': _t('role_warehouse'), 'accountant': _t('role_accountant'),
|
||||
'workshop': 'Taller', 'mechanic': 'Mecanico'
|
||||
'workshop': 'Taller', 'mechanic': 'Mecanico', 'driver': 'Repartidor'
|
||||
};
|
||||
var roleLabel = roleLabels[role] || role;
|
||||
var initials = name.split(' ').map(function(p) { return p[0]; }).join('').toUpperCase().substring(0, 2);
|
||||
@@ -199,6 +199,8 @@
|
||||
homeHref = '/pos/sale';
|
||||
} else if (userRole === 'workshop' && userPerms.indexOf('workshop.view') !== -1 && moduleEnabled('workshop')) {
|
||||
homeHref = '/pos/workshop';
|
||||
} else if (userRole === 'driver' && userPerms.indexOf('pos.remission') !== -1) {
|
||||
homeHref = '/pos/remission-notes';
|
||||
}
|
||||
document.querySelectorAll('a[href="/pos/dashboard"]').forEach(function(a) {
|
||||
a.setAttribute('href', homeHref);
|
||||
@@ -208,8 +210,8 @@
|
||||
function isPageAllowed(pagePath, userRole, userPerms) {
|
||||
if (userRole === 'owner' || userRole === 'admin') return true;
|
||||
|
||||
// Restricted roles (workshop/mechanic/counter/cashier) see modules based on permissions.
|
||||
if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
|
||||
// Restricted roles (workshop/mechanic/counter/cashier/driver) see modules based on permissions.
|
||||
if (['workshop', 'mechanic', 'counter', 'cashier', 'driver'].indexOf(userRole) !== -1) {
|
||||
var allowed = [];
|
||||
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
|
||||
allowed = ['/pos/workshop'];
|
||||
@@ -226,7 +228,8 @@
|
||||
'quotations.view': '/pos/quotations',
|
||||
'accounting.view': '/pos/accounting',
|
||||
'reports.view': '/pos/reports',
|
||||
'dashboard.view': '/pos/dashboard'
|
||||
'dashboard.view': '/pos/dashboard',
|
||||
'yonke.view': '/pos/yonke'
|
||||
};
|
||||
for (var p in permMap) {
|
||||
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||
@@ -251,7 +254,7 @@
|
||||
var fallback = null;
|
||||
if (userRole === 'owner' || userRole === 'admin') {
|
||||
fallback = '/pos/dashboard';
|
||||
} else if (['workshop', 'mechanic', 'counter', 'cashier'].indexOf(userRole) !== -1) {
|
||||
} else if (['workshop', 'mechanic', 'counter', 'cashier', 'driver'].indexOf(userRole) !== -1) {
|
||||
var allowed = [];
|
||||
if ((userRole === 'workshop' || userRole === 'mechanic') && moduleEnabled('workshop')) {
|
||||
allowed = ['/pos/workshop'];
|
||||
@@ -268,7 +271,8 @@
|
||||
'quotations.view': '/pos/quotations',
|
||||
'accounting.view': '/pos/accounting',
|
||||
'reports.view': '/pos/reports',
|
||||
'dashboard.view': '/pos/dashboard'
|
||||
'dashboard.view': '/pos/dashboard',
|
||||
'yonke.view': '/pos/yonke'
|
||||
};
|
||||
for (var p in permMap) {
|
||||
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||
@@ -350,13 +354,18 @@
|
||||
backBtn.style.display = 'none';
|
||||
})();
|
||||
|
||||
// ─── Service Worker update handler ───
|
||||
// ─── Service Worker update handler (patrón controllerchange) ───
|
||||
// Recarga SOLO cuando el SW que controlaba esta página fue reemplazado
|
||||
// (deploy con assets nuevos). La primera activación (sin controlador
|
||||
// previo) NO recarga — así se elimina el doble refresh al cambiar de
|
||||
// pestaña tras cada deploy.
|
||||
var _hadSwController = !!navigator.serviceWorker.controller;
|
||||
var _swRefreshing = false;
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', function (event) {
|
||||
if (event.data && event.data.type === 'SW_UPDATED') {
|
||||
console.log('[AppInit] SW updated to', event.data.cacheName, '— reloading...');
|
||||
window.location.reload();
|
||||
}
|
||||
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||
if (!_hadSwController || _swRefreshing) return;
|
||||
_swRefreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
this.setSearch('');
|
||||
this.buildBreadcrumb();
|
||||
var self = this;
|
||||
fetch('/pos/api/catalog/categories?mye_id=' + encodeURIComponent(myeId) + '&mode=oem', { headers: this._headers() })
|
||||
fetch('/pos/api/catalog/categories?mye_id=' + encodeURIComponent(myeId) + '&mode=local', { headers: this._headers() })
|
||||
.then(function(r) {
|
||||
if (!self._checkAuth(r)) return null;
|
||||
return r.json();
|
||||
|
||||
@@ -112,7 +112,8 @@ const Config = (() => {
|
||||
warehouse: 'Almacenista',
|
||||
accountant: 'Contador',
|
||||
workshop: 'Taller',
|
||||
mechanic: 'Mecanico'
|
||||
mechanic: 'Mecanico',
|
||||
driver: 'Repartidor'
|
||||
};
|
||||
|
||||
var ROLE_BADGE = {
|
||||
@@ -123,7 +124,8 @@ const Config = (() => {
|
||||
warehouse: 'badge--yellow',
|
||||
accountant: 'badge--purple',
|
||||
workshop: 'badge--orange',
|
||||
mechanic: 'badge--teal'
|
||||
mechanic: 'badge--teal',
|
||||
driver: 'badge--gray'
|
||||
};
|
||||
|
||||
function roleBadge(role) {
|
||||
@@ -513,6 +515,15 @@ const Config = (() => {
|
||||
setChecked('receipt-show-iva', d.show_iva_breakdown);
|
||||
setChecked('receipt-show-payment', d.show_payment_details);
|
||||
setChecked('receipt-show-employee', d.show_employee);
|
||||
setChecked('receipt-auto-print', d.auto_print);
|
||||
fetch(API + '/invoice-settings', { headers: headers() }).then(function (r) { return r.json(); }).then(function (s) {
|
||||
var a = document.getElementById('cfg-invoice-auto');
|
||||
var b = document.getElementById('cfg-invoice-email');
|
||||
var c = document.getElementById('cfg-invoice-global');
|
||||
if (a) a.checked = !!s.invoice_auto;
|
||||
if (b) b.checked = !!s.invoice_email;
|
||||
if (c) c.checked = !!s.invoice_global;
|
||||
}).catch(function () {});
|
||||
renderReceiptLogoThumb();
|
||||
} catch (e) {
|
||||
console.error('Config.loadReceiptConfig:', e);
|
||||
@@ -599,13 +610,26 @@ const Config = (() => {
|
||||
show_iva_breakdown: getChecked('receipt-show-iva'),
|
||||
show_payment_details: getChecked('receipt-show-payment'),
|
||||
show_employee: getChecked('receipt-show-employee'),
|
||||
invoice_auto: document.getElementById('cfg-invoice-auto') ? document.getElementById('cfg-invoice-auto').checked : undefined,
|
||||
invoice_email: document.getElementById('cfg-invoice-email') ? document.getElementById('cfg-invoice-email').checked : undefined,
|
||||
invoice_global: document.getElementById('cfg-invoice-global') ? document.getElementById('cfg-invoice-global').checked : undefined,
|
||||
auto_print: getChecked('receipt-auto-print'),
|
||||
};
|
||||
try {
|
||||
var inv = {};
|
||||
['invoice_auto', 'invoice_email', 'invoice_global'].forEach(function (k) {
|
||||
if (data[k] !== undefined) { inv[k] = data[k]; delete data[k]; }
|
||||
});
|
||||
var res = await fetch(API + '/receipt', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (Object.keys(inv).length) {
|
||||
await fetch(API + '/invoice-settings', {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify(inv),
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
throw new Error(err.error || 'Error al guardar');
|
||||
@@ -628,6 +652,7 @@ const Config = (() => {
|
||||
await saveAllowedBrands();
|
||||
await saveModules();
|
||||
await saveSalesSettings();
|
||||
await saveAlertContacts();
|
||||
await saveReceiptConfig();
|
||||
toast('Configuración guardada', 'ok');
|
||||
} catch (e) {
|
||||
@@ -842,6 +867,53 @@ const Config = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tier Discounts (Taller / Mayoreo)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadTierDiscounts() {
|
||||
try {
|
||||
var res = await fetch('/pos/api/inventory/tier-discounts', { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
var d = await res.json();
|
||||
(d.data || []).forEach(function(t) {
|
||||
var el = document.getElementById('cfg-tier-disc-' + t.tier_id);
|
||||
if (el) el.value = t.discount_pct;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Config.loadTierDiscounts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTierDiscounts() {
|
||||
var el2 = document.getElementById('cfg-tier-disc-2');
|
||||
var el3 = document.getElementById('cfg-tier-disc-3');
|
||||
var statusEl = document.getElementById('tier-discount-status');
|
||||
var btn = document.getElementById('btn-save-tier-discounts');
|
||||
if (!el2 || !el3) return;
|
||||
|
||||
var d2 = Math.max(0, Math.min(100, parseFloat(el2.value) || 0));
|
||||
var d3 = Math.max(0, Math.min(100, parseFloat(el3.value) || 0));
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
try {
|
||||
var res = await fetch('/pos/api/inventory/tier-discounts', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
throw new Error(err.error || 'Save failed');
|
||||
}
|
||||
toast('Descuentos por tipo de cliente guardados');
|
||||
if (statusEl) statusEl.textContent = 'Taller -' + d2 + '% · Mayoreo -' + d3 + '%';
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Descuentos'; }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vehicle Compatibility Source
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1059,6 +1131,50 @@ const Config = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Alert contacts (per-business WhatsApp alerts)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadAlertContacts() {
|
||||
try {
|
||||
var res = await fetch(API + '/alert-contacts', { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
var data = await res.json();
|
||||
var input = document.getElementById('alert-phones');
|
||||
if (input) input.value = (data.alert_phones || []).join(', ');
|
||||
var cb = document.getElementById('cfg-alert-whatsapp-enabled');
|
||||
if (cb) cb.checked = data.alert_whatsapp_enabled === true;
|
||||
} catch (e) {
|
||||
console.error('Config.loadAlertContacts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAlertContacts() {
|
||||
if (!checkAuth()) return;
|
||||
var input = document.getElementById('alert-phones');
|
||||
var cb = document.getElementById('cfg-alert-whatsapp-enabled');
|
||||
if (!input && !cb) return;
|
||||
var btn = document.getElementById('btn-save-alert-contacts');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
try {
|
||||
var res = await fetch(API + '/alert-contacts', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
alert_phones: input ? input.value : '',
|
||||
alert_whatsapp_enabled: cb ? cb.checked : false,
|
||||
}),
|
||||
});
|
||||
var data = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
if (!res.ok) throw new Error(data.error || 'Error al guardar');
|
||||
if (input) input.value = (data.alert_phones || []).join(', ');
|
||||
toast('Contactos de alerta guardados', 'ok');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar alertas'; }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tab navigation
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1293,6 +1409,12 @@ const Config = (() => {
|
||||
btnBrands.addEventListener('click', saveAllowedBrands);
|
||||
}
|
||||
|
||||
// Alert contacts save button (per-business WhatsApp alerts)
|
||||
var btnAlertContacts = document.getElementById('btn-save-alert-contacts');
|
||||
if (btnAlertContacts) {
|
||||
btnAlertContacts.addEventListener('click', saveAlertContacts);
|
||||
}
|
||||
|
||||
// Kiosk mode toggle
|
||||
var kioskToggle = document.getElementById('cfg-kiosk-mode');
|
||||
if (kioskToggle && window.NexusKiosk) {
|
||||
@@ -1322,7 +1444,9 @@ const Config = (() => {
|
||||
loadAllowedBrands();
|
||||
loadModules();
|
||||
loadSalesSettings();
|
||||
loadAlertContacts();
|
||||
loadReceiptConfig();
|
||||
loadTierDiscounts();
|
||||
if (isAdmin) {
|
||||
loadRolePermissions();
|
||||
loadWorkshopPermissions();
|
||||
@@ -1353,8 +1477,10 @@ const Config = (() => {
|
||||
deleteBranch,
|
||||
loadBusiness, saveBusiness, saveTaxParams, saveAll,
|
||||
loadCurrency, saveCurrency,
|
||||
loadTierDiscounts, saveTierDiscounts,
|
||||
loadVehicleCompatSource, saveVehicleCompatSource,
|
||||
loadModules, saveModules,
|
||||
loadAlertContacts, saveAlertContacts,
|
||||
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
|
||||
openModal, closeModal, openBranchModal, editBranch,
|
||||
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions,
|
||||
|
||||
@@ -30,6 +30,10 @@ const Customers = (() => {
|
||||
return '$' + n.toFixed(0);
|
||||
};
|
||||
|
||||
const esc = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + getToken() };
|
||||
}
|
||||
@@ -281,6 +285,7 @@ const Customers = (() => {
|
||||
set('detailEmail', c.email);
|
||||
set('detailSince', formatDate(c.created_at));
|
||||
set('detailLastPurchase', formatDate(c.last_purchase));
|
||||
if (typeof loadCustomerCRM === 'function') loadCustomerCRM(c.id);
|
||||
|
||||
// Credit
|
||||
const limit = parseFloat(c.credit_limit || 0);
|
||||
@@ -733,6 +738,137 @@ const Customers = (() => {
|
||||
if (typeof openSlidePanel === 'function') openSlidePanel();
|
||||
}
|
||||
|
||||
|
||||
// ─── CRM & Loyalty ───────────────────────
|
||||
async function loadCustomerCRM(customerId) {
|
||||
try {
|
||||
const [loy, acts, analytics, creditScore] = await Promise.all([
|
||||
api(`/pos/api/customers/${customerId}/loyalty`),
|
||||
api(`/pos/api/customers/${customerId}/activities`).catch(() => ({ data: [] })),
|
||||
api(`/pos/api/customers/${customerId}/analytics`).catch(() => null),
|
||||
api(`/pos/api/customers/${customerId}/credit-score`).catch(() => null),
|
||||
]);
|
||||
const balEl = document.getElementById('crmLoyaltyBalance');
|
||||
if (balEl) balEl.textContent = (loy.balance != null ? loy.balance : 0);
|
||||
const actsEl = document.getElementById('crmActivities');
|
||||
if (actsEl) {
|
||||
const list = acts.data || acts.activities || [];
|
||||
if (!list.length) {
|
||||
actsEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin actividad registrada</span>';
|
||||
} else {
|
||||
actsEl.innerHTML = list.slice(0, 20).map(a =>
|
||||
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${esc(a.created_at || '')} · ${esc(a.activity_type || '')}</div>
|
||||
<div style="font-size:var(--text-body-sm);">${esc(a.description || a.title || '')}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
}
|
||||
renderCustomerInsights(analytics, creditScore);
|
||||
} catch (e) {
|
||||
console.warn('CRM load failed:', e);
|
||||
}
|
||||
}
|
||||
window.loadCustomerCRM = loadCustomerCRM;
|
||||
|
||||
// Renders analytics (LTV, next purchase, churn) + credit risk score into
|
||||
// the CRM section of the customer detail panel. All values come from the
|
||||
// backend; missing data is shown as "—" rather than guessed.
|
||||
function renderCustomerInsights(analytics, creditScore) {
|
||||
const section = document.getElementById('crmSection');
|
||||
if (!section) return;
|
||||
let el = document.getElementById('crmInsights');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'crmInsights';
|
||||
el.style.cssText = 'display:grid;grid-template-columns:repeat(2,1fr);gap:var(--space-2);' +
|
||||
'margin-bottom:var(--space-3);';
|
||||
const actsEl = document.getElementById('crmActivities');
|
||||
section.insertBefore(el, actsEl || null);
|
||||
}
|
||||
if (!analytics && !creditScore) { el.innerHTML = ''; return; }
|
||||
|
||||
const card = (label, value, sub, color) =>
|
||||
`<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-2);">
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-wider);">${esc(label)}</div>
|
||||
<div style="font-size:var(--text-body-sm);font-weight:700;color:${color || 'var(--color-text-primary)'};">${value}</div>
|
||||
${sub ? `<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
|
||||
const cards = [];
|
||||
const a = analytics || {};
|
||||
|
||||
cards.push(card('Valor de vida (LTV)', fmt(a.ltv),
|
||||
`${(a.total_orders || 0)} compras · ticket ${fmt(a.avg_order_value)}`));
|
||||
|
||||
cards.push(card('Intervalo de compra',
|
||||
(a.avg_days_between_purchases != null && a.avg_days_between_purchases > 0)
|
||||
? `${a.avg_days_between_purchases} días` : '—',
|
||||
(a.days_since_last_purchase != null)
|
||||
? `Última hace ${a.days_since_last_purchase} días` : 'Sin compras'));
|
||||
|
||||
let nextSub = '';
|
||||
if (a.days_until_next_purchase != null) {
|
||||
nextSub = a.days_until_next_purchase >= 0
|
||||
? `En ${a.days_until_next_purchase} días`
|
||||
: `Vencida hace ${Math.abs(a.days_until_next_purchase)} días`;
|
||||
}
|
||||
cards.push(card('Próxima compra probable',
|
||||
a.predicted_next_purchase_date ? formatDate(a.predicted_next_purchase_date) : '—',
|
||||
nextSub));
|
||||
|
||||
const riskColor = a.churn_risk === 'high' ? 'var(--color-error)'
|
||||
: a.churn_risk === 'medium' ? '#d97706' : 'var(--color-success, #16a34a)';
|
||||
const riskLabel = a.churn_risk === 'high' ? 'Alto' : a.churn_risk === 'medium' ? 'Medio' : 'Bajo';
|
||||
const probTxt = (a.churn_probability != null) ? ` (${Math.round(a.churn_probability * 100)}%)` : '';
|
||||
cards.push(card('Riesgo de abandono (churn)',
|
||||
`<span style="color:${riskColor};">${riskLabel}${probTxt}</span>`,
|
||||
esc(a.churn_reason || '')));
|
||||
|
||||
if (creditScore && creditScore.score != null) {
|
||||
const tierMap = { good: 'Bueno', medium: 'Medio', risk: 'Riesgo' };
|
||||
const tierColor = creditScore.tier === 'good' ? 'var(--color-success, #16a34a)'
|
||||
: creditScore.tier === 'medium' ? '#d97706' : 'var(--color-error)';
|
||||
const reasons = (creditScore.reasons || []).slice(0, 2).map(esc).join(' · ');
|
||||
cards.push(card('Score de crédito',
|
||||
`<span style="color:${tierColor};">${creditScore.score}/100 · ${esc(tierMap[creditScore.tier] || creditScore.tier)}</span>`,
|
||||
`Límite sugerido ${fmt(creditScore.recommended_limit)} · ${esc(creditScore.data_points)} señales` +
|
||||
(reasons ? `<br>${reasons}` : '')));
|
||||
}
|
||||
|
||||
el.innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
async function crmRedeem() {
|
||||
if (!currentCustomer) return;
|
||||
const pts = prompt('Puntos a canjear:');
|
||||
if (!pts) return;
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/loyalty/redeem`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ points: parseInt(pts, 10) }),
|
||||
});
|
||||
alert('Canje registrado');
|
||||
loadCustomerCRM(currentCustomer.id);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function crmAddNote() {
|
||||
if (!currentCustomer) return;
|
||||
const note = prompt('Nota sobre el cliente:');
|
||||
if (!note) return;
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/activities`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ activity_type: 'note', description: note }),
|
||||
});
|
||||
loadCustomerCRM(currentCustomer.id);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────
|
||||
function init() {
|
||||
// Auth check
|
||||
@@ -1011,6 +1147,8 @@ const Customers = (() => {
|
||||
}
|
||||
};
|
||||
|
||||
publicApi.crmRedeem = crmRedeem;
|
||||
publicApi.crmAddNote = crmAddNote;
|
||||
// Expose globally for inline HTML onclick handlers
|
||||
window.Customers = publicApi;
|
||||
return publicApi;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
497
pos/static/js/inventory.js.meli
Normal file
497
pos/static/js/inventory.js.meli
Normal file
@@ -0,0 +1,497 @@
|
||||
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
|
||||
|
||||
var meliPreviewData = {};
|
||||
var meliCategoryAttrs = [];
|
||||
var meliCategorySearchTimeout;
|
||||
var meliCatItems = [];
|
||||
var meliCatActiveIndex = -1;
|
||||
|
||||
window.openMeliPublishModal = function() {
|
||||
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
|
||||
document.getElementById('meliPublishModal').classList.add('is-open');
|
||||
document.getElementById('meliPublishResult').innerHTML = '';
|
||||
document.getElementById('meliCategoryId').value = '';
|
||||
document.getElementById('meliCategorySearch').value = '';
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
document.getElementById('meliAttrsSection').style.display = 'none';
|
||||
document.getElementById('meliAttrsGrid').innerHTML = '';
|
||||
meliCategoryAttrs = [];
|
||||
refreshMeliPublishPreview();
|
||||
};
|
||||
|
||||
window.closeMeliPublishModal = function() {
|
||||
document.getElementById('meliPublishModal').classList.remove('is-open');
|
||||
};
|
||||
|
||||
// ==== RESTAURADAS v4 (MELI + descuentos) ====
|
||||
|
||||
window.searchMeliCategories = function() {
|
||||
var q = document.getElementById('meliCategorySearch').value.trim();
|
||||
var resultsDiv = document.getElementById('meliCategoryResults');
|
||||
if (q.length < 2) { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; return; }
|
||||
clearTimeout(meliCategorySearchTimeout);
|
||||
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
|
||||
meliCategorySearchTimeout = setTimeout(function() {
|
||||
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var cats = data.categories || [];
|
||||
meliCatItems = cats.slice(0, 10);
|
||||
meliCatActiveIndex = -1;
|
||||
if (!meliCatItems.length) { resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-empty">Sin resultados</div></div>'; return; }
|
||||
var html = '<div class="meli-cat-dropdown">';
|
||||
meliCatItems.forEach(function(c, idx) {
|
||||
html += '<div class="meli-cat-item" data-idx="' + idx + '" onmouseenter="highlightMeliCat(' + idx + ')" onmousedown="selectMeliCategoryIdx(' + idx + ')">' +
|
||||
'<span>' + esc(c.category_name || c.category_id) + '</span>' +
|
||||
'<span class="cat-id">' + esc(c.category_id) + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
resultsDiv.innerHTML = html;
|
||||
})
|
||||
.catch(function() { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; });
|
||||
}, 300);
|
||||
};
|
||||
|
||||
window.handleMeliCatKeydown = function(e) {
|
||||
if (!meliCatItems.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
meliCatActiveIndex = Math.min(meliCatActiveIndex + 1, meliCatItems.length - 1);
|
||||
highlightMeliCat(meliCatActiveIndex);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
meliCatActiveIndex = Math.max(meliCatActiveIndex - 1, -1);
|
||||
highlightMeliCat(meliCatActiveIndex);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (meliCatActiveIndex >= 0) selectMeliCategoryIdx(meliCatActiveIndex);
|
||||
} else if (e.key === 'Escape') {
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
}
|
||||
};
|
||||
|
||||
/* Cerrar dropdown al hacer click fuera */
|
||||
document.addEventListener('click', function(e) {
|
||||
var field = document.getElementById('meliCategorySearch');
|
||||
var results = document.getElementById('meliCategoryResults');
|
||||
if (field && results && !field.contains(e.target) && !results.contains(e.target)) {
|
||||
results.innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
window.onMeliShippingChange = function() {
|
||||
var mode = document.getElementById('meliShippingMode').value;
|
||||
var costField = document.getElementById('meliShippingCostField');
|
||||
if (costField) costField.style.display = (mode === 'custom') ? 'block' : 'none';
|
||||
};
|
||||
|
||||
// ─── MELI publish helpers ──────────────────────────────────────────────
|
||||
// Contrato de `custom_data` esperado por validate_items / publish_items
|
||||
// (pos/services/marketplace_external_service.py):
|
||||
// {
|
||||
// titles: { "<inventory_id>": "Título (<=60)" },
|
||||
// prices: { "<inventory_id>": 123.45 },
|
||||
// stocks: { "<inventory_id>": 3 },
|
||||
// attributes: { "<inventory_id>": [{ id: "BRAND", value_name: "Nissan" }] },
|
||||
// shipping_cost: 150 // solo se usa si shipping_mode === 'custom'
|
||||
// }
|
||||
// Las claves son el id de inventario como string (el backend consulta con str(inv_id)).
|
||||
function _meliAttrValue(attr) {
|
||||
// Los atributos con lista cerrada se pintan como <select> + input "Otra (escribir)";
|
||||
// el resto como un input de texto libre.
|
||||
var selEl = document.getElementById('meliAttrSel-' + attr.id);
|
||||
if (selEl) {
|
||||
if (selEl.value === '__other__') {
|
||||
var otherEl = document.getElementById('meliAttrOther-' + attr.id);
|
||||
return otherEl ? otherEl.value.trim() : '';
|
||||
}
|
||||
return (selEl.value || '').trim();
|
||||
}
|
||||
var el = document.getElementById('meliAttr-' + attr.id);
|
||||
return el ? (el.value || '').trim() : '';
|
||||
}
|
||||
|
||||
function _collectMeliCustomData() {
|
||||
var ids = Array.from(selectedItems);
|
||||
var customData = { titles: {}, prices: {}, stocks: {}, attributes: {} };
|
||||
ids.forEach(function(id) {
|
||||
var titleEl = document.getElementById('meliTitle-' + id);
|
||||
var priceEl = document.getElementById('meliPrice-' + id);
|
||||
var stockEl = document.getElementById('meliStock-' + id);
|
||||
if (titleEl && titleEl.value.trim()) customData.titles[id] = titleEl.value.trim();
|
||||
if (priceEl) {
|
||||
var price = parseFloat(priceEl.value);
|
||||
if (!isNaN(price)) customData.prices[id] = price;
|
||||
}
|
||||
if (stockEl) {
|
||||
var stock = parseInt(stockEl.value, 10);
|
||||
if (!isNaN(stock)) customData.stocks[id] = stock;
|
||||
}
|
||||
var attrs = [];
|
||||
(meliCategoryAttrs || []).forEach(function(attr) {
|
||||
var value = _meliAttrValue(attr);
|
||||
if (attr.id && value) attrs.push({ id: attr.id, value_name: value });
|
||||
});
|
||||
if (attrs.length) customData.attributes[id] = attrs;
|
||||
});
|
||||
// El backend solo lo aplica con shipping_mode === 'custom'; si el campo no
|
||||
// existe o está vacío simplemente se omite.
|
||||
var shippingCostEl = document.getElementById('meliShippingCost');
|
||||
var shippingCost = shippingCostEl ? parseFloat(shippingCostEl.value) : NaN;
|
||||
if (!isNaN(shippingCost)) customData.shipping_cost = shippingCost;
|
||||
return customData;
|
||||
}
|
||||
|
||||
function _renderPublishResult(data, resultEl) {
|
||||
if (!resultEl) return;
|
||||
data = data || {};
|
||||
var successList = data.success || [];
|
||||
var failedList = data.failed || [];
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + successList.length + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failedList.length + ' fallo(s)</span></div>';
|
||||
if (successList.length) {
|
||||
html += '<ul style="margin:0 0 var(--space-2);padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
successList.forEach(function(s) {
|
||||
var itemId = s.external_item_id || s.item_id;
|
||||
var label = 'Item #' + esc(s.inventory_id);
|
||||
if (itemId) {
|
||||
label += ' → <a href="' + esc(s.permalink || '#') + '" target="_blank" rel="noopener" style="color:var(--color-primary);">' + esc(itemId) + '</a>';
|
||||
}
|
||||
html += '<li>' + label + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
if (failedList.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
failedList.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
if (successList.length > 0) {
|
||||
selectedItems.clear();
|
||||
updateSelectionUI();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function _pollMeliAsync(taskId, resultEl, btn) {
|
||||
var attempts = 0;
|
||||
var maxAttempts = 60; // ~2 min (una consulta cada 2s)
|
||||
var interval = setInterval(function() {
|
||||
attempts++;
|
||||
fetch('/pos/api/marketplace-ext/listings/async/' + encodeURIComponent(taskId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>';
|
||||
return;
|
||||
}
|
||||
if (data.status === 'done') {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
_renderPublishResult(data.result || {}, resultEl);
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 3000);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Timeout esperando resultado. Revisa la pestaña Publicaciones más tarde.</span>';
|
||||
} else {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando en segundo plano... (' + (attempts * 2) + 's)</span>';
|
||||
}
|
||||
}).catch(function() {
|
||||
clearInterval(interval);
|
||||
if (btn) btn.disabled = false;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Error consultando progreso</span>';
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
window.validateMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
var listingType = document.getElementById('meliListingType').value;
|
||||
var shippingMode = document.getElementById('meliShippingMode').value;
|
||||
var ids = Array.from(selectedItems);
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliValidateBtn');
|
||||
btn.disabled = true;
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Validando con MercadoLibre...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listings/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode,
|
||||
custom_data: _collectMeliCustomData()
|
||||
})
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
btn.disabled = false;
|
||||
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
var valid = (data.valid || []);
|
||||
var invalid = (data.invalid || []);
|
||||
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + valid.length + ' válido(s)</span> · <span style="color:var(--color-error);">❌ ' + invalid.length + ' inválido(s)</span></div>';
|
||||
|
||||
// Show earnings estimate table for valid items
|
||||
if (valid.length > 0) {
|
||||
html += '<div style="margin:var(--space-3) 0;border:1px solid var(--color-border);border-radius:var(--radius-md);overflow:hidden;">';
|
||||
html += '<div style="background:var(--color-surface-2);padding:var(--space-2) var(--space-3);font-size:var(--text-caption);font-weight:600;color:var(--color-text-primary);">💰 Estimado de ganancia (después de comisiones ML)</div>';
|
||||
html += '<table style="width:100%;border-collapse:collapse;font-size:var(--text-caption);">';
|
||||
html += '<tr style="background:var(--color-surface-1);"><th style="padding:6px 8px;text-align:left;">Item</th><th style="padding:6px 8px;text-align:right;">Precio</th><th style="padding:6px 8px;text-align:right;">Comisión</th><th style="padding:6px 8px;text-align:right;">Recibes</th></tr>';
|
||||
valid.forEach(function(v) {
|
||||
var price = v.price || 0;
|
||||
var fee = v.fee_amount || 0;
|
||||
var net = v.net_amount || 0;
|
||||
var feePct = v.fee_pct || 0;
|
||||
html += '<tr style="border-top:1px solid var(--color-border);">';
|
||||
html += '<td style="padding:6px 8px;">#' + esc(v.inventory_id) + '</td>';
|
||||
html += '<td style="padding:6px 8px;text-align:right;">$' + price.toFixed(2) + '</td>';
|
||||
html += '<td style="padding:6px 8px;text-align:right;color:var(--color-error);">-$' + fee.toFixed(2) + ' (' + feePct.toFixed(1) + '%)</td>';
|
||||
html += '<td style="padding:6px 8px;text-align:right;color:var(--color-success);font-weight:600;">$' + net.toFixed(2) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
if (valid.some(function(v){ return v.fee_source === 'approximation'; })) {
|
||||
html += '<div style="padding:var(--space-2) var(--space-3);font-size:10px;color:var(--color-text-muted);background:var(--color-surface-0);">* Comisión estimada. El monto real puede variar según ML.</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (invalid.length) {
|
||||
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
|
||||
invalid.forEach(function(f) {
|
||||
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
resultEl.innerHTML = html;
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
window.executeMeliPublish = function() {
|
||||
var categoryId = document.getElementById('meliCategoryId').value.trim();
|
||||
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
|
||||
var listingType = document.getElementById('meliListingType').value;
|
||||
var shippingMode = document.getElementById('meliShippingMode').value;
|
||||
var ids = Array.from(selectedItems);
|
||||
var resultEl = document.getElementById('meliPublishResult');
|
||||
var btn = document.getElementById('meliPublishBtn');
|
||||
btn.disabled = true;
|
||||
var useAsync = ids.length > 3;
|
||||
var endpoint = useAsync ? '/pos/api/marketplace-ext/listings/async' : '/pos/api/marketplace-ext/listings';
|
||||
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">' + (useAsync ? 'Encolando ' : 'Publicando ') + ids.length + ' producto(s)...</span>';
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inventory_ids: ids,
|
||||
category_id: categoryId,
|
||||
listing_type: listingType,
|
||||
shipping_mode: shippingMode,
|
||||
custom_data: _collectMeliCustomData()
|
||||
})
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
|
||||
if (useAsync && data.task_id) {
|
||||
_pollMeliAsync(data.task_id, resultEl, btn);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
_renderPublishResult(data, resultEl);
|
||||
if ((data.success || []).length > 0) {
|
||||
setTimeout(function() { closeMeliPublishModal(); }, 2500);
|
||||
}
|
||||
}
|
||||
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
|
||||
};
|
||||
|
||||
function refreshMeliPublishPreview() {
|
||||
var container = document.getElementById('meliPublishItemsPreview');
|
||||
var countEl = document.getElementById('meliPublishSelectedCount');
|
||||
countEl.textContent = selectedItems.size + ' producto(s) seleccionado(s)';
|
||||
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando verificaciones...</p>';
|
||||
|
||||
var ids = Array.from(selectedItems);
|
||||
fetch('/pos/api/marketplace-ext/inventory-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventory_ids: ids })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.items) { container.innerHTML = '<p style="color:var(--color-error);">Error cargando preview</p>'; return; }
|
||||
var html = '';
|
||||
data.items.forEach(function(it) {
|
||||
if (!it.exists) {
|
||||
html += '<div class="meli-preview-card" style="opacity:0.6;"><div style="color:var(--color-error);">Item #' + it.inventory_id + ' no encontrado</div></div>';
|
||||
return;
|
||||
}
|
||||
meliPreviewData[it.inventory_id] = it;
|
||||
var checks = '';
|
||||
checks += '<span class="meli-check ' + (it.has_image ? 'ok' : 'fail') + '">' + (it.has_image ? '✅' : '❌') + ' Imagen</span>';
|
||||
checks += '<span class="meli-check ' + (it.has_stock ? 'ok' : 'fail') + '">' + (it.has_stock ? '✅' : '❌') + ' Stock</span>';
|
||||
checks += '<span class="meli-check ' + (it.has_price ? 'ok' : 'fail') + '">' + (it.has_price ? '✅' : '❌') + ' Precio</span>';
|
||||
if (it.already_published) {
|
||||
checks += '<span class="meli-check ok">✅ <a href="' + esc(it.existing_listing.permalink || '#') + '" target="_blank" style="color:var(--color-success);text-decoration:underline;">Ya publicado (' + esc(it.existing_listing.status) + ')</a></span>';
|
||||
}
|
||||
var imgSrc = it.image_url || '';
|
||||
var imgHtml = imgSrc ? '<img src="' + esc(imgSrc) + '" alt="" onerror="this.style.display=\'none\'">' : '<div style="width:56px;height:56px;background:var(--color-surface-2);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--color-text-muted);">Sin img</div>';
|
||||
if (!it.has_image) {
|
||||
imgHtml = '<div class="meli-img-upload" onclick="document.getElementById(\'meliImgUpload-' + it.inventory_id + '\').click()">' +
|
||||
'<div style="font-size:10px;">+ Subir</div>' +
|
||||
'<input type="file" id="meliImgUpload-' + it.inventory_id + '" accept="image/*" onchange="handleMeliImageUpload(' + it.inventory_id + ', this)">' +
|
||||
'</div>';
|
||||
}
|
||||
html += '<div class="meli-preview-card" id="meliCard-' + it.inventory_id + '">' +
|
||||
imgHtml +
|
||||
'<div>' +
|
||||
'<input type="text" class="meli-title-input" id="meliTitle-' + it.inventory_id + '" value="' + esc(it.title) + '" maxlength="60" oninput="updateMeliTitleCount(' + it.inventory_id + ')">' +
|
||||
'<div style="font-size:10px;color:var(--color-text-muted);text-align:right;" id="meliTitleCount-' + it.inventory_id + '">' + it.title.length + '/60</div>' +
|
||||
'<div class="meli-checks-row">' + checks + '</div>' +
|
||||
'</div>' +
|
||||
'<div><label style="font-size:10px;color:var(--color-text-muted);">Precio</label><input type="number" class="meli-num-input" id="meliPrice-' + it.inventory_id + '" value="' + it.price + '" oninput="scheduleMeliFeeEstimates()"></div>' +
|
||||
'<div><label style="font-size:10px;color:var(--color-text-muted);">Stock</label><input type="number" class="meli-num-input" id="meliStock-' + it.inventory_id + '" value="' + it.stock + '"></div>' +
|
||||
'<div id="meliFee-' + it.inventory_id + '" style="font-size:10px;color:var(--color-text-muted);min-width:130px;line-height:1.35;"></div>' +
|
||||
'</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
scheduleMeliFeeEstimates();
|
||||
}).catch(function() { container.innerHTML = '<p style="color:var(--color-error);">Error de red</p>'; });
|
||||
}
|
||||
|
||||
window.selectMeliCategory = function(id, name) {
|
||||
document.getElementById('meliCategoryId').value = id;
|
||||
document.getElementById('meliCategorySearch').value = name;
|
||||
document.getElementById('meliCategoryResults').innerHTML = '';
|
||||
meliCatItems = [];
|
||||
meliCatActiveIndex = -1;
|
||||
loadCategoryAttributes(id);
|
||||
scheduleMeliFeeEstimates();
|
||||
};
|
||||
|
||||
window.selectMeliCategoryIdx = function(idx) {
|
||||
var c = meliCatItems[idx];
|
||||
if (!c) return;
|
||||
selectMeliCategory(c.category_id, c.category_name || c.category_id);
|
||||
};
|
||||
|
||||
window.highlightMeliCat = function(idx) {
|
||||
meliCatActiveIndex = idx;
|
||||
var items = document.querySelectorAll('.meli-cat-item');
|
||||
items.forEach(function(el, i) { el.classList.toggle('is-active', i === idx); });
|
||||
};
|
||||
|
||||
window.loadCategoryAttributes = function(categoryId) {
|
||||
var grid = document.getElementById('meliAttrsGrid');
|
||||
var section = document.getElementById('meliAttrsSection');
|
||||
grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando atributos...</p>';
|
||||
section.style.display = 'block';
|
||||
fetch('/pos/api/marketplace-ext/categories/' + encodeURIComponent(categoryId) + '/attributes', { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
meliCategoryAttrs = data.attributes || [];
|
||||
if (!meliCategoryAttrs.length) { grid.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay atributos obligatorios adicionales.</p>'; return; }
|
||||
var html = '';
|
||||
meliCategoryAttrs.forEach(function(attr) {
|
||||
var attrId = esc(attr.id);
|
||||
var attrName = esc(attr.name || attr.id);
|
||||
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
|
||||
if (attr.values && attr.values.length) {
|
||||
// Some ML attributes (like BRAND) have a closed list but the API still
|
||||
// accepts free-text via value_name. Provide a select + "Other" fallback.
|
||||
var selectId = 'meliAttrSel-' + attrId;
|
||||
var otherId = 'meliAttrOther-' + attrId;
|
||||
inputHtml = '<select class="meli-title-input" id="' + selectId + '" onchange="onMeliAttrSelectChange(\'' + attrId + '\')">' +
|
||||
'<option value="">Selecciona ' + attrName + '</option>' +
|
||||
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
|
||||
'<option value="__other__">Otra marca (escribir)...</option>' +
|
||||
'</select>' +
|
||||
'<input type="text" class="meli-title-input" id="' + otherId + '" placeholder="Escribe la ' + attrName + '" style="display:none;margin-top:6px;">';
|
||||
}
|
||||
html += '<div class="inv-field" id="meliAttrWrap-' + attrId + '"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
|
||||
});
|
||||
grid.innerHTML = html;
|
||||
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
|
||||
};
|
||||
|
||||
window.updateMeliTitleCount = function(id) {
|
||||
var el = document.getElementById('meliTitle-' + id);
|
||||
var countEl = document.getElementById('meliTitleCount-' + id);
|
||||
if (el && countEl) countEl.textContent = el.value.length + '/60';
|
||||
};
|
||||
|
||||
window.handleMeliImageUpload = function(itemId, input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var file = input.files[0];
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
fetch('/pos/api/inventory/items/' + itemId + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + getToken() },
|
||||
body: formData
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.image_url) {
|
||||
showToast('Imagen subida', 'success');
|
||||
refreshMeliPublishPreview();
|
||||
if (inventoryVS) inventoryVS.refresh();
|
||||
} else {
|
||||
showToast(data.error || 'Error subiendo imagen', 'error');
|
||||
}
|
||||
}).catch(function(){ showToast('Error de red', 'error'); });
|
||||
};
|
||||
|
||||
// ─── Estimador de comisiones MeLi (por artículo) ───
|
||||
var meliFeeTimeout;
|
||||
function fmtMoney(n) {
|
||||
return '$' + (parseFloat(n) || 0).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
function updateMeliFeeEstimates() {
|
||||
var categoryId = (document.getElementById('meliCategoryId') || {}).value;
|
||||
var listingType = (document.getElementById('meliListingType') || {}).value || 'gold_special';
|
||||
var ids = Object.keys(window.meliPreviewData || (typeof meliPreviewData !== 'undefined' ? meliPreviewData : {}));
|
||||
if (!ids.length) return;
|
||||
ids.forEach(function (id) {
|
||||
var feeEl = document.getElementById('meliFee-' + id);
|
||||
if (!feeEl) return;
|
||||
if (!categoryId) { feeEl.innerHTML = ''; return; }
|
||||
var priceEl = document.getElementById('meliPrice-' + id);
|
||||
var price = parseFloat(priceEl && priceEl.value) || 0;
|
||||
if (price <= 0) { feeEl.innerHTML = '<span style="color:var(--color-text-muted);">Ingresa un precio para estimar comisiones</span>'; return; }
|
||||
feeEl.innerHTML = '<span style="color:var(--color-text-muted);">Calculando comisiones...</span>';
|
||||
fetch('/pos/api/marketplace-ext/listing-prices?price=' + encodeURIComponent(price) + '&category_id=' + encodeURIComponent(categoryId), { headers: { 'Authorization': 'Bearer ' + getToken() } })
|
||||
.then(function (r) { return r.json().then(function (d) { return {ok: r.ok, data: d}; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok || res.data.error) { feeEl.innerHTML = '<span style="color:var(--color-warning);">No se pudo estimar: ' + esc(res.data.error || 'error') + '</span>'; return; }
|
||||
var offers = res.data.listing_prices || [];
|
||||
var offer = null;
|
||||
offers.forEach(function (o) { if (o.listing_type_id === listingType) offer = o; });
|
||||
if (!offer && offers.length) offer = offers[0];
|
||||
if (!offer || !offer.sale_fee_details) { feeEl.innerHTML = ''; return; }
|
||||
var fee = offer.sale_fee_details.gross_amount || 0;
|
||||
var pct = offer.sale_fee_details.percentage_fee || 0;
|
||||
var net = price - fee;
|
||||
feeEl.innerHTML = '💰 Comisión MeLi: <strong style="color:var(--color-warning);">' + fmtMoney(fee) + ' (' + pct + '%)</strong>'
|
||||
+ ' · Recibes: <strong style="color:' + (net > 0 ? 'var(--color-success)' : 'var(--color-error)') + ';">' + fmtMoney(net) + '</strong>'
|
||||
+ ' <span style="opacity:.65;">(estimado, sin costo de envío)</span>';
|
||||
})
|
||||
.catch(function () { feeEl.innerHTML = ''; });
|
||||
});
|
||||
}
|
||||
function scheduleMeliFeeEstimates() {
|
||||
clearTimeout(meliFeeTimeout);
|
||||
meliFeeTimeout = setTimeout(updateMeliFeeEstimates, 500);
|
||||
}
|
||||
window.scheduleMeliFeeEstimates = scheduleMeliFeeEstimates;
|
||||
|
||||
// =====================================================================
|
||||
// EXPOSE GLOBALS (for onclick handlers in HTML)
|
||||
@@ -86,6 +86,8 @@
|
||||
var role = (result.data.employee.role || '').toLowerCase();
|
||||
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
||||
window.location.href = '/pos/workshop';
|
||||
} else if (role === 'driver') {
|
||||
window.location.href = '/pos/remission-notes';
|
||||
} else {
|
||||
window.location.href = '/pos/catalog';
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
connectedDiv.style.display = 'block';
|
||||
document.getElementById('connectedNickname').textContent = cfg.meli_user_id || 'Usuario ML';
|
||||
document.getElementById('connectedSite').textContent = cfg.meli_site_id || 'MLM';
|
||||
if (!cfg.has_refresh_token) {
|
||||
statusDiv.innerHTML += '<div style="margin-top:8px;padding:10px;border-radius:6px;background:rgba(234,179,8,.12);color:#eab308;font-size:var(--text-caption);">⚠️ Esta conexión <strong>no tiene token de larga duración</strong> (refresh token) y expirará en ~6 horas. Habilite el scope <strong>offline_access</strong> en su aplicación de MercadoLibre Developers y vuelva a conectar para corregirlo.</div>';
|
||||
}
|
||||
} else {
|
||||
statusDiv.innerHTML = '<span class="meli-status meli-status--pending">● No conectado</span>';
|
||||
formDiv.style.display = 'block';
|
||||
@@ -133,6 +136,7 @@
|
||||
if (!res.ok) throw new Error('Failed to load listings');
|
||||
var data = await res.json();
|
||||
listingsData = data.items || [];
|
||||
listingsData.forEach(function(l) { l.external_status = normStatus(l.external_status); });
|
||||
renderListings();
|
||||
} catch (e) {
|
||||
container.innerHTML = renderEmptyState({
|
||||
@@ -244,6 +248,7 @@
|
||||
if (!res.ok) throw new Error('Failed to load orders');
|
||||
var data = await res.json();
|
||||
ordersData = data.items || [];
|
||||
ordersData.forEach(function(o) { o.status = normStatus(o.status); });
|
||||
renderOrders();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">' + renderEmptyState({
|
||||
@@ -304,6 +309,7 @@
|
||||
var res = await fetch(API + '/orders/' + id, { headers: headers() });
|
||||
var o = await res.json();
|
||||
if (!res.ok) throw new Error(o.error || 'Error');
|
||||
o.status = normStatus(o.status);
|
||||
|
||||
var itemsHtml = (o.items || []).map(function(it) {
|
||||
return '<tr><td>' + escapeHtml(it.title || '—') + '</td><td>' + it.quantity + '</td><td style="text-align:right">$' + (it.unit_price || 0).toFixed(2) + '</td></tr>';
|
||||
@@ -368,6 +374,11 @@
|
||||
|
||||
// ─── Utils ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ML devuelve el estado de las preguntas en MAYÚSCULAS (UNANSWERED/ANSWERED/CLOSED)
|
||||
// mientras que nuestros filtros y literales usan minúsculas -> comparar
|
||||
// siempre en minúsculas para que las no respondidas aparezcan y se puedan responder.
|
||||
function normStatus(s) { return String(s == null ? '' : s).toLowerCase(); }
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
var div = document.createElement('div');
|
||||
@@ -394,7 +405,11 @@
|
||||
});
|
||||
var data = await res.json();
|
||||
if (res.ok) {
|
||||
alert('¡Conectado exitosamente con MercadoLibre!');
|
||||
if (data.warning === 'no_refresh_token') {
|
||||
alert('Conectado con MercadoLibre, pero con una advertencia:\n\nMercadoLibre NO devolvió un token de larga duración (refresh token), así que la conexión expirará en ~6 horas.\n\nPara corregirlo: entre a developers.mercadolibre.com → su aplicación → Editar → habilite el scope "offline_access" y vuelva a conectar.');
|
||||
} else {
|
||||
alert('¡Conectado exitosamente con MercadoLibre!');
|
||||
}
|
||||
window.history.replaceState({}, document.title, '/pos/marketplace-external');
|
||||
loadConfig();
|
||||
} else {
|
||||
@@ -415,9 +430,9 @@
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
var data = await res.json();
|
||||
var items = data.items || [];
|
||||
var active = items.filter(function(l) { return l.external_status === 'active'; }).length;
|
||||
var paused = items.filter(function(l) { return l.external_status === 'paused'; }).length;
|
||||
var closed = items.filter(function(l) { return l.external_status === 'closed'; }).length;
|
||||
var active = items.filter(function(l) { return normStatus(l.external_status) === 'active'; }).length;
|
||||
var paused = items.filter(function(l) { return normStatus(l.external_status) === 'paused'; }).length;
|
||||
var closed = items.filter(function(l) { return normStatus(l.external_status) === 'closed'; }).length;
|
||||
var total = items.length;
|
||||
|
||||
var html = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
|
||||
@@ -489,6 +504,7 @@
|
||||
if (!res.ok) throw new Error('Failed to load questions');
|
||||
var data = await res.json();
|
||||
questionsData = data.items || [];
|
||||
questionsData.forEach(function(q) { q.status = normStatus(q.status); });
|
||||
renderQuestions();
|
||||
} catch (e) {
|
||||
container.innerHTML = renderEmptyState({
|
||||
@@ -506,14 +522,14 @@
|
||||
var search = document.getElementById('questionSearch').value.toLowerCase();
|
||||
|
||||
var filtered = questionsData.filter(function(q) {
|
||||
if (statusFilter && q.status !== statusFilter) return false;
|
||||
if (statusFilter && normStatus(q.status) !== normStatus(statusFilter)) return false;
|
||||
if (search && !((q.question_text || '').toLowerCase().includes(search)) && !((q.listing_title || '').toLowerCase().includes(search))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Stats bar
|
||||
var unanswered = questionsData.filter(function(q) { return q.status === 'unanswered'; }).length;
|
||||
var answered = questionsData.filter(function(q) { return q.status === 'answered'; }).length;
|
||||
var unanswered = questionsData.filter(function(q) { return normStatus(q.status) === 'unanswered'; }).length;
|
||||
var answered = questionsData.filter(function(q) { return normStatus(q.status) === 'answered'; }).length;
|
||||
var total = questionsData.length;
|
||||
var statsHtml = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
|
||||
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-primary);">' + total + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Total preguntas</div></div>' +
|
||||
@@ -533,10 +549,10 @@
|
||||
}
|
||||
|
||||
container.innerHTML = filtered.map(function(q) {
|
||||
var statusClass = 'meli-status--' + (q.status || 'pending');
|
||||
var statusLabel = q.status === 'unanswered' ? 'Sin responder' : (q.status === 'answered' ? 'Respondida' : (q.status || '—'));
|
||||
var statusClass = 'meli-status--' + (normStatus(q.status) || 'pending');
|
||||
var statusLabel = normStatus(q.status) === 'unanswered' ? 'Sin responder' : (normStatus(q.status) === 'answered' ? 'Respondida' : (q.status || '—'));
|
||||
var answerHtml = '';
|
||||
if (q.status === 'unanswered') {
|
||||
if (normStatus(q.status) === 'unanswered') {
|
||||
answerHtml = '<div style="margin-top:var(--space-2);">' +
|
||||
'<textarea class="meli-title-input" id="qAnswer-' + q.id + '" rows="2" placeholder="Escribe tu respuesta..."></textarea>' +
|
||||
'<button class="btn btn--primary btn--xs" style="margin-top:6px;" onclick="submitAnswer(' + q.id + ')">Enviar respuesta</button>' +
|
||||
|
||||
@@ -39,6 +39,8 @@ const POS = (() => {
|
||||
let receiptConfig = {};
|
||||
let couriers = [];
|
||||
let selectedCourierId = null;
|
||||
let crossSellTimer = null;
|
||||
let crossSellItems = [];
|
||||
|
||||
// Currency-aware formatter: reads pos_currency from localStorage
|
||||
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
||||
@@ -46,13 +48,29 @@ const POS = (() => {
|
||||
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
||||
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
// Price based on customer tier: base price (price_1) with tier discount.
|
||||
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||
function priceForTier(basePrice, tier) {
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Price based on customer tier.
|
||||
// Prefers the tenant's real tier prices (price_2/price_3, materialized
|
||||
// from the tier discounts config) and falls back to the legacy formula.
|
||||
function priceForTier(basePrice, tier, item) {
|
||||
const p = parseFloat(basePrice) || 0;
|
||||
const t = parseInt(tier, 10) || 1;
|
||||
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||
if (t === 2) {
|
||||
const p2 = item ? parseFloat(item.price_2) : 0;
|
||||
if (p2 > 0) return p2;
|
||||
return Math.round(p * 0.95 * 100) / 100;
|
||||
}
|
||||
if (t === 3) {
|
||||
const p3 = item ? parseFloat(item.price_3) : 0;
|
||||
if (p3 > 0) return p3;
|
||||
return Math.round(p * 0.90 * 100) / 100;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -209,6 +227,10 @@ const POS = (() => {
|
||||
await loadRegister();
|
||||
await loadReceiptConfig();
|
||||
await loadCouriers();
|
||||
await restoreCartState();
|
||||
replayOfflineSales();
|
||||
scheduleCrossSell();
|
||||
if (navigator.onLine === false && typeof showOfflineBanner === 'function') showOfflineBanner(true);
|
||||
|
||||
// Setup event listeners
|
||||
setupKeyboard();
|
||||
@@ -259,6 +281,11 @@ const POS = (() => {
|
||||
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
|
||||
});
|
||||
}
|
||||
const saleSel = document.getElementById('saleCourier');
|
||||
if (saleSel) {
|
||||
saleSel.innerHTML = '<option value="">— Sin repartidor —</option>' +
|
||||
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load couriers:', e);
|
||||
couriers = [];
|
||||
@@ -402,18 +429,82 @@ const POS = (() => {
|
||||
|
||||
renderCart();
|
||||
showToast(`${item.name || 'Articulo'} agregado`);
|
||||
scheduleCrossSell();
|
||||
}
|
||||
|
||||
function removeFromCart(index) {
|
||||
cart.splice(index, 1);
|
||||
if (selectedRow >= cart.length) selectedRow = cart.length - 1;
|
||||
renderCart();
|
||||
scheduleCrossSell();
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
cart.length = 0;
|
||||
selectedRow = -1;
|
||||
renderCart();
|
||||
hideCrossSell();
|
||||
}
|
||||
|
||||
// ─── Cross-sell (A10) ────────────────
|
||||
// Debounced call to /pos/api/recommendations. The panel lives in
|
||||
// pos.html (#crossSellPanel / #crossSellList).
|
||||
function scheduleCrossSell() {
|
||||
if (crossSellTimer) clearTimeout(crossSellTimer);
|
||||
if (!cart.length) { hideCrossSell(); return; }
|
||||
crossSellTimer = setTimeout(refreshCrossSell, 350);
|
||||
}
|
||||
|
||||
async function refreshCrossSell() {
|
||||
const panel = document.getElementById('crossSellPanel');
|
||||
const list = document.getElementById('crossSellList');
|
||||
if (!panel || !list) return;
|
||||
const ids = cart.map(c => c.inventory_id).filter(Boolean);
|
||||
if (!ids.length) { hideCrossSell(); return; }
|
||||
try {
|
||||
const data = await api('/pos/api/recommendations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventory_ids: ids, limit: 4 })
|
||||
});
|
||||
crossSellItems = data.recommendations || [];
|
||||
} catch (e) {
|
||||
hideCrossSell();
|
||||
return;
|
||||
}
|
||||
const inCart = new Set(ids);
|
||||
const items = crossSellItems.filter(r => !inCart.has(r.inventory_id));
|
||||
if (!items.length) { hideCrossSell(); return; }
|
||||
list.innerHTML = items.map(r => `
|
||||
<button type="button" onclick="POS.addCrossSellItem(${r.inventory_id})"
|
||||
title="${esc(r.reason || '')}"
|
||||
style="display:flex;justify-content:space-between;align-items:center;gap:8px;text-align:left;width:100%;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:5px 8px;cursor:pointer;color:var(--color-text-primary);">
|
||||
<span style="display:flex;flex-direction:column;overflow:hidden;">
|
||||
<span style="font-size:var(--text-body-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(r.name || '')}</span>
|
||||
<span style="font-size:var(--text-caption);color:var(--color-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(r.reason || '')}</span>
|
||||
</span>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--text-body-sm);white-space:nowrap;">${fmt(r.price)}</span>
|
||||
</button>`).join('');
|
||||
panel.style.display = '';
|
||||
}
|
||||
|
||||
function hideCrossSell() {
|
||||
if (crossSellTimer) { clearTimeout(crossSellTimer); crossSellTimer = null; }
|
||||
const panel = document.getElementById('crossSellPanel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
function addCrossSellItem(inventoryId) {
|
||||
const rec = crossSellItems.find(r => r.inventory_id === inventoryId);
|
||||
if (!rec) return;
|
||||
addToCart({
|
||||
inventory_id: rec.inventory_id,
|
||||
name: rec.name,
|
||||
part_number: rec.part_number,
|
||||
quantity: 1,
|
||||
unit_price: rec.price || 0,
|
||||
price_1: rec.price || 0,
|
||||
stock: rec.stock || 0,
|
||||
});
|
||||
}
|
||||
|
||||
function openCancelModal() {
|
||||
@@ -504,6 +595,32 @@ const POS = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
function saveCartState() {
|
||||
try {
|
||||
sessionStorage.setItem('pos_cart_state', JSON.stringify({
|
||||
cart, currentCustomerId: currentCustomer ? currentCustomer.id : null,
|
||||
}));
|
||||
} catch (e) { /* storage lleno o bloqueado */ }
|
||||
}
|
||||
|
||||
async function restoreCartState() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('pos_cart_state');
|
||||
if (!raw) return;
|
||||
const state = JSON.parse(raw);
|
||||
if (!state || !Array.isArray(state.cart) || !state.cart.length) return;
|
||||
cart = state.cart;
|
||||
if (state.currentCustomerId) {
|
||||
try {
|
||||
const c = await api('/pos/api/customers/' + state.currentCustomerId);
|
||||
if (c && c.data) currentCustomer = c.data;
|
||||
} catch (e) { /* cliente ya no existe */ }
|
||||
}
|
||||
renderCart();
|
||||
showToast('Carrito restaurado (' + cart.length + ' partidas)', 'ok');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
const tbody = document.getElementById('cartBody');
|
||||
const table = document.getElementById('cartTable');
|
||||
@@ -558,6 +675,7 @@ const POS = (() => {
|
||||
tbody.innerHTML = html;
|
||||
updateTotals();
|
||||
updateAvgMargin();
|
||||
saveCartState();
|
||||
}
|
||||
|
||||
function updateQty(index, val) {
|
||||
@@ -646,6 +764,17 @@ const POS = (() => {
|
||||
}
|
||||
|
||||
// ─── Search ──────────────────────────
|
||||
let lastSearchResults = [];
|
||||
let lastSearchTier = 1;
|
||||
let searchActiveIndex = -1;
|
||||
|
||||
function highlightSearchResult() {
|
||||
const rows = document.querySelectorAll('#searchResults > div[data-search-idx]');
|
||||
rows.forEach((el, i) => {
|
||||
el.style.background = i === searchActiveIndex ? 'var(--color-primary-muted)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('itemSearch');
|
||||
if (!input) return;
|
||||
@@ -654,9 +783,35 @@ const POS = (() => {
|
||||
searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
if (!lastSearchResults.length) return;
|
||||
e.preventDefault();
|
||||
searchActiveIndex = e.key === 'ArrowDown'
|
||||
? Math.min(searchActiveIndex + 1, lastSearchResults.length - 1)
|
||||
: Math.max(searchActiveIndex - 1, 0);
|
||||
highlightSearchResult();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
searchItems(input.value.trim());
|
||||
const q = input.value.trim();
|
||||
if (!q) return;
|
||||
const norm = (s) => (s || '').toUpperCase().replace(/[\s\-\._]/g, '');
|
||||
// 0-click scanner: exact barcode/part_number match adds directly
|
||||
const exact = lastSearchResults.find(r =>
|
||||
norm(r.part_number) === norm(q) || norm(r.barcode || '') === norm(q));
|
||||
if (exact) {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : lastSearchTier;
|
||||
addFromSearch(exact, priceForTier(exact.price_1, tier, exact));
|
||||
return;
|
||||
}
|
||||
if (searchActiveIndex >= 0 && lastSearchResults[searchActiveIndex]) {
|
||||
const item = lastSearchResults[searchActiveIndex];
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : lastSearchTier;
|
||||
addFromSearch(item, priceForTier(item.price_1, tier, item));
|
||||
return;
|
||||
}
|
||||
searchItems(q);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
@@ -665,23 +820,74 @@ const POS = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function cacheSearch(q, items) {
|
||||
try {
|
||||
const cache = JSON.parse(localStorage.getItem('pos_search_cache') || '{}');
|
||||
cache[q.toLowerCase()] = { items: items.slice(0, 12), ts: Date.now() };
|
||||
const keys = Object.keys(cache);
|
||||
if (keys.length > 40) keys.slice(0, 5).forEach(k => delete cache[k]);
|
||||
localStorage.setItem('pos_search_cache', JSON.stringify(cache));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function searchOffline(q) {
|
||||
try {
|
||||
const cache = JSON.parse(localStorage.getItem('pos_search_cache') || '{}');
|
||||
const ql = q.toLowerCase();
|
||||
let hits = [];
|
||||
Object.keys(cache).forEach(function (k) {
|
||||
if (ql.indexOf(k) !== -1 || k.indexOf(ql) !== -1) hits = hits.concat(cache[k].items);
|
||||
});
|
||||
const seen = new Set();
|
||||
return hits.filter(function (i) {
|
||||
if (seen.has(i.id)) return false; seen.add(i.id); return true;
|
||||
}).slice(0, 12);
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function searchItems(q) {
|
||||
if (!q || q.length < 2) { hideSearchResults(); return; }
|
||||
|
||||
if (navigator.onLine === false) {
|
||||
const cached = searchOffline(q);
|
||||
const container = document.getElementById('searchResults');
|
||||
const totals = document.getElementById('totalsPanel');
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
lastSearchResults = cached;
|
||||
lastSearchTier = tier;
|
||||
searchActiveIndex = -1;
|
||||
container.innerHTML = '<div style="padding:8px var(--space-4);background:var(--color-warning-muted,#fef3c7);font-size:var(--text-caption);">Sin conexión — resultados guardados</div>'
|
||||
+ (cached.length ? cached.map(function (item, idx) {
|
||||
const price = priceForTier(item.price_1, tier, item);
|
||||
return `<div data-search-idx="${idx}" style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div><div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | Stock: ${item.stock}</div></div>
|
||||
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div></div>`;
|
||||
}).join('') : '<div style="padding:16px;text-align:center;color:var(--color-text-muted);">Sin resultados guardados para esta búsqueda</div>');
|
||||
container.style.display = '';
|
||||
totals.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(`/pos/api/inventory/items?q=${encodeURIComponent(q)}&per_page=20`);
|
||||
const container = document.getElementById('searchResults');
|
||||
const totals = document.getElementById('totalsPanel');
|
||||
|
||||
if (data.data.length === 0) {
|
||||
searchActiveIndex = -1;
|
||||
lastSearchResults = data.data || [];
|
||||
lastSearchTier = 1;
|
||||
if (lastSearchResults.length) cacheSearch(q, lastSearchResults);
|
||||
|
||||
if (lastSearchResults.length === 0) {
|
||||
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
||||
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
||||
} else {
|
||||
let html = '';
|
||||
data.data.forEach(item => {
|
||||
lastSearchResults.forEach((item, idx) => {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
const price = priceForTier(item.price_1, tier);
|
||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
const price = priceForTier(item.price_1, tier, item);
|
||||
html += `<div data-search-idx="${idx}" style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="POS_searchHover(${idx})" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
|
||||
@@ -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 => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px var(--space-3);border-bottom:1px solid var(--color-border);">
|
||||
<input type="checkbox" id="ret-chk-${it.id}" checked style="width:auto;" />
|
||||
<span style="flex:1;font-size:var(--text-body-sm);">${it.name || it.part_number} (${it.part_number})</span>
|
||||
<span style="font-size:var(--text-caption);color:var(--color-text-muted);">cant: ${it.quantity}</span>
|
||||
<input type="number" id="ret-qty-${it.id}" class="form-input" min="1" max="${it.quantity}" value="${it.quantity}" style="width:70px;" />
|
||||
<input type="text" id="ret-reason-${it.id}" class="form-input" placeholder="Motivo" style="width:160px;" />
|
||||
</div>`).join('');
|
||||
document.getElementById('returnFooter').style.display = 'flex';
|
||||
} catch (e) {
|
||||
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<span style="color:var(--color-error);">Selecciona al menos una partida</span>';
|
||||
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 =
|
||||
'<span style="color:var(--color-success);">Devolución registrada: reembolso ' + fmt(res.total_refund || 0) + '</span>';
|
||||
renderCart();
|
||||
} catch (e) {
|
||||
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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 = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay apartados activos</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows.map(l => {
|
||||
const balance = (l.total || 0) - (l.amount_paid || 0);
|
||||
return `<div style="display:flex;align-items:center;gap:10px;padding:10px var(--space-3);border-bottom:1px solid var(--color-border);cursor:pointer;"
|
||||
onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''"
|
||||
onclick="POS.selectLayaway(${l.id}, ${l.total}, ${l.amount_paid || 0}, '${(l.customer_name || '').replace(/'/g, "\\'")}')">
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:var(--font-weight-semibold);">Apartado #${l.id} — ${l.customer_name || 'Sin cliente'}</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Vence: ${l.expires_at || '—'}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-weight:var(--font-weight-bold);">${fmt(balance)}</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">de ${fmt(l.total)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = '<span style="color:var(--color-error);">Error cargando apartados</span>';
|
||||
}
|
||||
}
|
||||
function selectLayaway(id, total, paid, customerName) {
|
||||
currentLayawayId = id;
|
||||
document.getElementById('layawayActions').style.display = 'block';
|
||||
document.getElementById('layawayDetail').innerHTML =
|
||||
`<strong>Apartado #${id}</strong> — ${customerName} — Saldo: <strong>${fmt(total - paid)}</strong>`;
|
||||
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 = '<span style="color:var(--color-success);">Abono registrado</span>';
|
||||
loadLayaways();
|
||||
} catch (e) {
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<span style="color:var(--color-success);">Apartado liquidado' + (res.id ? ' — Venta #' + res.id : '') + '</span>';
|
||||
loadLayaways();
|
||||
} catch (e) {
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<span style="color:var(--color-success);">Apartado cancelado</span>';
|
||||
loadLayaways();
|
||||
} catch (e) {
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── 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 = '<div style="padding:14px;text-align:center;color:var(--color-text-muted);">Buscando vehículo...</div>';
|
||||
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 = '<div style="padding:10px var(--space-4);background:var(--color-primary-muted);font-weight:var(--font-weight-semibold);">🚗 Piezas compatibles: ' + esc(label || 'vehículo') + ' (' + items.length + ')</div>';
|
||||
if (!items.length) {
|
||||
html += '<div style="padding:16px;text-align:center;color:var(--color-text-muted);">Sin piezas compatibles registradas aún</div>';
|
||||
} else {
|
||||
items.forEach(item => {
|
||||
const price = priceForTier(item.price_1, tier, item);
|
||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
|
||||
<div style="font-size:var(--text-caption);color:${item.stock <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};">Stock: ${item.stock}</div>
|
||||
</div>
|
||||
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="padding:14px;text-align:center;color:var(--color-error);">' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
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 = (() => {
|
||||
<span>Cliente: ${customerName}</span>
|
||||
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
||||
</div>
|
||||
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||
${sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||
<hr class="divider">
|
||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||
<span class="qty">Cant</span>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
|
||||
moduleEnabled('catalog') ? { id: 'catalog', name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
|
||||
{ id: 'inventory', name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
|
||||
{ id: 'yonke', name: 'Yonke (usadas)', href: '/pos/yonke', icon: '<path d="M5 17H3v-4l2-5.5A2 2 0 0 1 6.9 6h10.2a2 2 0 0 1 1.9 1.5L21 13v4h-2"/><circle cx="7.5" cy="17" r="2"/><circle cx="16.5" cy="17" r="2"/><path d="M9.5 17h5"/>' },
|
||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||
{ label: _t('nav_management'), items: [
|
||||
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
|
||||
3024
pos/static/js/yonke.js
Normal file
3024
pos/static/js/yonke.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user