// /home/Autopartes/pos/static/js/pos.js /** * POS Frontend: sale processing, F-key shortcuts, payment modal, ticket printing. * * Communicates with: * - /pos/api/sales (pos_bp) * - /pos/api/quotations (pos_bp) * - /pos/api/layaways (pos_bp) * - /pos/api/customers (customers_bp) * - /pos/api/register (cashregister_bp) * - /pos/api/inventory/items (inventory_bp) — for item search * - /pos/api/catalog/search (catalog_bp) — for catalog search */ const POS = (() => { // ─── State ─────────────────────────── let token = localStorage.getItem('pos_token') || ''; let cart = []; let selectedRow = -1; let currentCustomer = null; let currentRegister = null; let paymentMethod = 'efectivo'; let canViewCost = false; let employeeMaxDiscount = 100; let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || null; let lastSaleData = null; let searchTimeout = null; let customerSearchTimeout = null; let canCancel = false; let canDiscount = false; let canEditPrice = false; let canCreateWorkshopOrder = false; let canCreateLayaway = false; let canCreateRemission = false; let counterRemissionEnabled = false; let allowZeroPriceSales = true; let allowNegativeStock = false; let workshopModuleEnabled = true; let currentPerms = []; let receiptConfig = {}; let couriers = []; let selectedCourierId = null; // Currency-aware formatter: reads pos_currency from localStorage const _posCurrency = localStorage.getItem('pos_currency') || 'MXN'; const _currSymbols = { MXN: '$', USD: 'US$' }; 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) { 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; return p; } function headers() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; } async function api(url, options = {}) { options.headers = headers(); const res = await fetch(url, options); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } function showToast(msg) { const container = document.getElementById('toastContainer'); if (!container) return; const el = document.createElement('div'); el.className = 'toast'; el.textContent = msg; container.appendChild(el); setTimeout(() => el.remove(), 2100); } // ─── Permission-based UI toggles ───── function applyPermissionsUI() { function hide(selector) { const el = document.querySelector(selector); if (el) el.style.display = 'none'; } function show(selector) { const el = document.querySelector(selector); if (el) el.style.display = ''; } if (!canCancel) { hide('#btnCancelSale'); hide('#fkeyEsc'); } if (!canDiscount) hide('[onclick="POS.applyDiscount()"]'); if (!canEditPrice) hide('[onclick="POS.modifyPrice()"]'); if (!canCreateWorkshopOrder || !workshopModuleEnabled) { hide('[onclick="POS.createServiceOrder()"]'); hide('[title="Orden de servicio"]'); } if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]'); // Counter remission workflow if (canCreateRemission) { hide('#btnCobrar'); hide('.fkey[onclick="POS.checkout()"]'); hide('[onclick="POS.createLayaway()"]'); hide('[onclick="POS.saveQuotation()"]'); show('#btnRemission'); show('#courierSelectField'); } else { hide('#btnRemission'); hide('#courierSelectField'); } if (!currentPerms.includes('pos.sell')) { hide('#btnPayRemission'); } else { show('#btnPayRemission'); } } // ─── Init ──────────────────────────── async function init() { // Parse JWT to get employee info try { const payload = JSON.parse(atob(token.split('.')[1])); document.getElementById('employeeName').textContent = payload.name || 'Empleado'; document.getElementById('branchName').textContent = payload.branch_name || ''; const perms = payload.permissions || []; currentPerms = perms; const employeeRole = payload.role || ''; canViewCost = perms.includes('pos.view_cost'); canCancel = perms.includes('pos.cancel'); canDiscount = perms.includes('pos.discount'); canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter'; canCreateWorkshopOrder = perms.includes('workshop.edit'); canCreateLayaway = perms.includes('pos.sell'); employeeMaxDiscount = payload.max_discount_pct || 100; // Counter remission feature try { const crCfg = await api('/pos/api/config/counter-remission'); counterRemissionEnabled = crCfg.enabled === true; } catch (e) { counterRemissionEnabled = false; } // Sales settings (zero-price and negative-stock toggles) try { const ssCfg = await api('/pos/api/config/sales-settings'); allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false; allowNegativeStock = ssCfg.allow_negative_stock === true; } catch (e) { allowZeroPriceSales = true; allowNegativeStock = false; } // Module toggles (from app-init preloaded modules) try { var modules = window.POS_USER && window.POS_USER.modules; if (!modules) modules = JSON.parse(localStorage.getItem('pos_modules') || '{}'); workshopModuleEnabled = modules.workshop !== false; } catch (e) { workshopModuleEnabled = true; } // Counter remission workflow applies only to the counter role. canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission'); // Show cost/margin columns and toggle button if permission if (canViewCost) { document.getElementById('thCost').style.display = ''; document.getElementById('thMargin').style.display = ''; const costToggle = document.getElementById('costToggle'); if (costToggle) costToggle.style.display = ''; } // Set avatar initials const avatar = document.querySelector('.status-bar__user-avatar'); if (avatar && payload.name) { const parts = payload.name.split(' '); avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase(); } applyPermissionsUI(); } catch (e) { console.warn('Could not parse token:', e); } // Load cart from localStorage (from catalog or quotation edit/convert) const catalogCart = localStorage.getItem('pos_cart'); const editQuoteId = localStorage.getItem('pos_edit_quote_id'); const convertQuoteId = localStorage.getItem('pos_convert_quote_id'); if (catalogCart) { try { const items = JSON.parse(catalogCart); for (const item of items) { addToCart(item); } localStorage.removeItem('pos_cart'); } catch (e) { console.warn('Could not load catalog cart:', e); } } if (editQuoteId) { showToast(`Modo edicion: Cotizacion #${editQuoteId}. Guardar actualizara la cotizacion.`); } if (convertQuoteId) { showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`); } // Load current register and receipt config await loadRegister(); await loadReceiptConfig(); await loadCouriers(); // Setup event listeners setupKeyboard(); setupSearch(); setupCustomerSearch(); } // ─── Register ──────────────────────── async function loadRegister() { try { const data = await api('/pos/api/register/current'); if (data.register) { currentRegister = data.register; document.getElementById('registerInfo').innerHTML = `Caja #${data.register.register_number}`; } else { currentRegister = null; document.getElementById('registerInfo').innerHTML = '⚠ Sin caja abierta — Clic para abrir'; // Force open register modal on first load only for users who can sell if (currentPerms.includes('pos.sell')) { showOpenRegisterModal(); } } } catch (e) { console.warn('Register check failed:', e); } } async function loadReceiptConfig() { try { receiptConfig = await api('/pos/api/config/receipt'); } catch (e) { console.warn('Could not load receipt config:', e); receiptConfig = {}; } } async function loadCouriers() { try { const data = await api('/pos/api/logistics/couriers'); couriers = data.couriers || []; const sel = document.getElementById('remissionCourier'); if (sel) { sel.innerHTML = '' + couriers.map(c => ``).join(''); sel.addEventListener('change', () => { selectedCourierId = sel.value ? parseInt(sel.value, 10) : null; }); } } catch (e) { console.warn('Could not load couriers:', e); couriers = []; } } function rc(key, fallback) { const v = receiptConfig[key]; return v !== undefined && v !== null && v !== '' ? v : fallback; } function showOpenRegisterModal() { document.getElementById('openRegisterModal').classList.add('open'); document.getElementById('registerOpenResult').innerHTML = ''; } function closeOpenRegisterModal() { document.getElementById('openRegisterModal').classList.remove('open'); document.getElementById('registerOpenResult').innerHTML = ''; } async function openRegister() { const number = parseInt(document.getElementById('regNumber').value); const amount = parseFloat(document.getElementById('regOpeningAmount').value) || 0; if (!number || number < 1) { document.getElementById('registerOpenResult').innerHTML = 'Numero de caja invalido'; return; } try { const data = await api('/pos/api/register/open', { method: 'POST', body: JSON.stringify({ register_number: number, opening_amount: amount }) }); if (data.error) { document.getElementById('registerOpenResult').innerHTML = '' + (data.error || 'Error') + ''; return; } currentRegister = data; document.getElementById('registerInfo').innerHTML = `Caja #${data.register_number}`; closeOpenRegisterModal(); showToast(`Caja #${data.register_number} abierta con $${amount.toFixed(2)}`); } catch (e) { document.getElementById('registerOpenResult').innerHTML = 'Error de red'; } } // ─── Cut X / Z (Close Register) ────── function showCutZModal() { document.getElementById('cutZModal').classList.add('open'); document.getElementById('cutZResult').innerHTML = ''; loadCutX(); } function closeCutZModal() { document.getElementById('cutZModal').classList.remove('open'); document.getElementById('cutZResult').innerHTML = ''; } async function loadCutX() { const el = document.getElementById('cutZSummary'); try { const data = await api('/pos/api/register/cut-x'); if (data.error) { el.innerHTML = '

' + data.error + '

'; return; } let html = '
'; html += '
Efectivo inicial
' + fmt(data.opening_amount) + '
'; html += '
Ventas totales
' + fmt(data.total_sales) + ' (' + data.total_sales_count + ')
'; html += '
Efectivo en ventas
' + fmt(data.cash_from_sales) + '
'; html += '
Cambio entregado
-' + fmt(data.change_given) + '
'; html += '
Entradas de efectivo
+' + fmt(data.cash_movements_in) + '
'; html += '
Salidas de efectivo
-' + fmt(data.cash_movements_out) + '
'; html += '
Cancelaciones
' + data.cancelled_count + ' (' + fmt(data.cancelled_amount) + ')
'; html += '
Efectivo esperado
' + fmt(data.expected_cash) + '
'; html += '
'; if (data.sales_by_method && Object.keys(data.sales_by_method).length) { html += '
'; html += 'Por metodo de pago:
'; for (const [method, info] of Object.entries(data.sales_by_method)) { html += '' + method + ': ' + fmt(info.amount) + ' (' + info.count + ')'; } html += '
'; } if (data.movement_detail && data.movement_detail.length) { html += '
'; html += 'Movimientos de caja:
'; data.movement_detail.forEach(function(m) { html += '
' + m.type + ' ' + fmt(m.amount) + ' — ' + (m.reason || '') + '
'; }); html += '
'; } el.innerHTML = html; document.getElementById('cutZClosingAmount').value = data.expected_cash.toFixed(2); } catch (e) { el.innerHTML = '

Error cargando resumen

'; } } async function confirmCutZ() { const amount = parseFloat(document.getElementById('cutZClosingAmount').value) || 0; try { const data = await api('/pos/api/register/cut-z', { method: 'POST', body: JSON.stringify({ closing_amount: amount }) }); if (data.error) { document.getElementById('cutZResult').innerHTML = '' + data.error + ''; return; } const diffColor = data.difference > 0 ? 'var(--color-success)' : (data.difference < 0 ? 'var(--color-error)' : 'var(--color-text-muted)'); document.getElementById('cutZResult').innerHTML = 'Caja cerrada correctamente'; document.getElementById('registerInfo').innerHTML = 'Sin caja abierta'; currentRegister = null; closeCutZModal(); showToast('Corte Z completado. Diferencia: $' + data.difference.toFixed(2)); } catch (e) { document.getElementById('cutZResult').innerHTML = 'Error de red'; } } // ─── Cart ──────────────────────────── function addToCart(item) { const existing = cart.find(c => c.inventory_id === item.inventory_id); if (existing) { existing.quantity += (item.quantity || 1); renderCart(); if (window.BarcodeFeedback) BarcodeFeedback.success(); return; } cart.push({ inventory_id: item.inventory_id || item.id, part_number: item.part_number || '', name: item.name || '', quantity: item.quantity || 1, unit_price: parseFloat(item.unit_price || item.price_1 || 0), unit_cost: parseFloat(item.cost || 0), discount_pct: parseFloat(item.discount_pct || 0), tax_rate: parseFloat(item.tax_rate || 0.16), stock: item.stock || 0, price_1: parseFloat(item.price_1 || 0), price_2: parseFloat(item.price_2 || 0), price_3: parseFloat(item.price_3 || 0), }); renderCart(); showToast(`${item.name || 'Articulo'} agregado`); } function removeFromCart(index) { cart.splice(index, 1); if (selectedRow >= cart.length) selectedRow = cart.length - 1; renderCart(); } function clearCart() { cart.length = 0; selectedRow = -1; renderCart(); } function openCancelModal() { if (!canCancel) { showToast('No tienes permiso para cancelar'); return; } const overlay = document.getElementById('overlay-cancelar-venta'); const dialog = document.getElementById('modal-cancelar-venta'); if (overlay) overlay.classList.add('active'); if (dialog) dialog.classList.add('active'); } function closeCancelModal() { const overlay = document.getElementById('overlay-cancelar-venta'); const dialog = document.getElementById('modal-cancelar-venta'); if (overlay) overlay.classList.remove('active'); if (dialog) dialog.classList.remove('active'); } function changeQuantity() { if (selectedRow < 0 || selectedRow >= cart.length) { showToast('Selecciona un articulo primero', 'warn'); return; } const q = prompt('Nueva cantidad:', cart[selectedRow].quantity); if (q !== null) { const n = parseInt(q); if (n > 0) { cart[selectedRow].quantity = n; renderCart(); } } } function applyDiscount() { if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; } if (selectedRow < 0 || selectedRow >= cart.length) { showToast('Selecciona un articulo primero', 'warn'); return; } const d = prompt('Descuento %:', cart[selectedRow].discount_pct); if (d !== null) { const n = parseFloat(d); if (n >= 0 && n <= 100) { cart[selectedRow].discount_pct = n; renderCart(); } } } function isZeroPriceItem(item) { const unitPrice = parseFloat(item.unit_price || 0); const qty = parseFloat(item.quantity || 1); const discount = parseFloat(item.discount_pct || 0); return (unitPrice * qty * (1 - discount / 100)) <= 0; } function findZeroPriceItem() { if (allowZeroPriceSales) return null; return cart.find(isZeroPriceItem) || null; } function modifyPrice() { if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; } if (selectedRow < 0 || selectedRow >= cart.length) { showToast('Selecciona un articulo primero', 'warn'); return; } const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price); if (p !== null) { const n = parseFloat(p); if (n < 0) { showToast('Precio no válido'); return; } if (!allowZeroPriceSales && n === 0) { showToast('No está permitido dejar el precio en $0'); return; } cart[selectedRow].unit_price = n; renderCart(); } } // Wire confirm-cancel button document.addEventListener('DOMContentLoaded', function() { var btn = document.getElementById('btnConfirmCancel'); if (btn) { btn.addEventListener('click', function() { clearCart(); closeCancelModal(); }); } }); function renderCart() { const tbody = document.getElementById('cartBody'); const table = document.getElementById('cartTable'); const empty = document.getElementById('cartEmpty'); if (cart.length === 0) { table.style.display = 'none'; empty.style.display = 'flex'; updateTotals(); return; } table.style.display = ''; empty.style.display = 'none'; let html = ''; cart.forEach((item, i) => { const lineGross = item.unit_price * item.quantity; const lineDiscount = lineGross * item.discount_pct / 100; const lineSubtotal = lineGross - lineDiscount; const costHtml = canViewCost ? `${fmt(item.unit_cost)}` : ''; let marginHtml = ''; if (canViewCost) { const effectivePrice = item.unit_price * (1 - item.discount_pct / 100); const marginPct = effectivePrice > 0 ? ((effectivePrice - item.unit_cost) / effectivePrice * 100).toFixed(1) : '0.0'; const marginVal = parseFloat(marginPct); const cls = marginVal > 30 ? 'margin-high' : marginVal > 15 ? 'margin-mid' : 'margin-low'; const color = marginVal > 30 ? 'var(--color-success)' : marginVal > 15 ? 'var(--color-warning)' : 'var(--color-error)'; marginHtml = `${marginPct}%`; } html += ` ${i + 1}
${item.name}
${item.part_number} | Stock: ${item.stock}
${fmt(item.unit_price)} % ${fmt(lineSubtotal)} ${costHtml} ${marginHtml} `; }); tbody.innerHTML = html; updateTotals(); updateAvgMargin(); } function updateQty(index, val) { const qty = Math.max(1, parseInt(val) || 1); cart[index].quantity = qty; renderCart(); } function updateDiscount(index, val) { let disc = Math.max(0, Math.min(100, parseFloat(val) || 0)); if (disc > employeeMaxDiscount) { alert(`Descuento maximo permitido: ${employeeMaxDiscount}%`); disc = employeeMaxDiscount; } cart[index].discount_pct = disc; renderCart(); } function selectRow(index) { selectedRow = index; renderCart(); } function updateTotals() { let subtotal = 0, discountTotal = 0, taxTotal = 0; cart.forEach(item => { const lineGross = item.unit_price * item.quantity; const lineDiscount = lineGross * item.discount_pct / 100; const lineAfterDiscount = lineGross - lineDiscount; const lineTax = lineAfterDiscount * item.tax_rate; subtotal += lineAfterDiscount; discountTotal += lineDiscount; taxTotal += lineTax; }); const total = subtotal + taxTotal; document.getElementById('dispSubtotal').textContent = fmt(subtotal); document.getElementById('dispTax').textContent = fmt(taxTotal); document.getElementById('dispTotal').textContent = fmt(total); if (discountTotal > 0) { document.getElementById('discountRow').style.display = ''; document.getElementById('dispDiscount').textContent = '-' + fmt(discountTotal); } else { document.getElementById('discountRow').style.display = 'none'; } } function updateAvgMargin() { const avgEl = document.getElementById('avgMargin'); if (!avgEl || !canViewCost) return; let totalRevenue = 0, totalCost = 0; cart.forEach(item => { if (item.unit_cost > 0) { const effectivePrice = item.unit_price * (1 - item.discount_pct / 100); totalRevenue += effectivePrice * item.quantity; totalCost += item.unit_cost * item.quantity; } }); if (totalRevenue > 0) { const avg = ((totalRevenue - totalCost) / totalRevenue * 100); avgEl.textContent = avg.toFixed(1) + '%'; avgEl.style.color = avg > 30 ? 'var(--color-success)' : avg > 15 ? 'var(--color-warning)' : 'var(--color-error)'; } else { avgEl.textContent = '--'; } } function getTotal() { let subtotal = 0, taxTotal = 0; cart.forEach(item => { const lineGross = item.unit_price * item.quantity; const lineDiscount = lineGross * item.discount_pct / 100; const lineAfterDiscount = lineGross - lineDiscount; const lineTax = lineAfterDiscount * item.tax_rate; subtotal += lineAfterDiscount; taxTotal += lineTax; }); return Math.round((subtotal + taxTotal) * 100) / 100; } function getItemCount() { return cart.reduce((sum, item) => sum + item.quantity, 0); } // ─── Search ────────────────────────── function setupSearch() { const input = document.getElementById('itemSearch'); if (!input) return; input.addEventListener('input', () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300); }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); searchItems(input.value.trim()); } if (e.key === 'Escape') { input.value = ''; hideSearchResults(); } }); } async function searchItems(q) { if (!q || q.length < 2) { hideSearchResults(); 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) { container.innerHTML = '
Sin resultados
'; if (window.BarcodeFeedback) BarcodeFeedback.error(); } else { let html = ''; data.data.forEach(item => { const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1; const price = priceForTier(item.price_1, tier); html += `
${item.name}
${item.part_number} | ${item.brand || ''}
Stock: ${item.stock}
${fmt(price)}
`; }); container.innerHTML = html; } container.style.display = ''; totals.style.display = 'none'; } catch (e) { console.error('Search error:', e); } } function addFromSearch(item, price) { addToCart({ inventory_id: item.id, part_number: item.part_number, name: item.name, unit_price: price, cost: item.cost, tax_rate: item.tax_rate, stock: item.stock, price_1: item.price_1, price_2: item.price_2, price_3: item.price_3, }); if (window.BarcodeFeedback) BarcodeFeedback.success(); hideSearchResults(); document.getElementById('itemSearch').value = ''; document.getElementById('itemSearch').focus(); } function hideSearchResults() { const sr = document.getElementById('searchResults'); const tp = document.getElementById('totalsPanel'); if (sr) sr.style.display = 'none'; if (tp) tp.style.display = ''; } // ─── Customer Search ───────────────── function setupCustomerSearch() { const input = document.getElementById('customerSearch'); if (!input) return; input.addEventListener('input', () => { clearTimeout(customerSearchTimeout); customerSearchTimeout = setTimeout(() => searchCustomers(input.value.trim()), 300); }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { input.value = ''; document.getElementById('customerAutocomplete').style.display = 'none'; } }); } async function searchCustomers(q) { if (!q || q.length < 2) { document.getElementById('customerAutocomplete').style.display = 'none'; return; } try { const data = await api(`/pos/api/customers?q=${encodeURIComponent(q)}&per_page=10`); const ac = document.getElementById('customerAutocomplete'); if (data.data.length === 0) { ac.innerHTML = '
Sin resultados
'; } else { let html = ''; data.data.forEach(c => { const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' }; html += `
${c.name}
${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}
`; }); ac.innerHTML = html; } ac.style.display = 'block'; } catch (e) { console.error('Customer search error:', e); } } function recalcCartPrices() { const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1; cart.forEach(item => { if (item.price_1 > 0) { item.unit_price = priceForTier(item.price_1, tier); } }); } async function selectCustomer(customer) { currentCustomer = customer; document.getElementById('customerAutocomplete').style.display = 'none'; document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none'; recalcCartPrices(); const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' }; document.getElementById('customerName').textContent = customer.name; document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1'; document.getElementById('customerCredit').textContent = `Credito: ${fmt(customer.credit_balance)} / ${fmt(customer.credit_limit)}`; document.getElementById('customerSelected').style.display = ''; // Show vehicle info banner if (customer.vehicle_info && customer.vehicle_info.length > 0) { const v = customer.vehicle_info[0]; document.getElementById('vehicleInfo').textContent = `${v.make || ''} ${v.model || ''} ${v.year || ''} ${v.plates ? '(' + v.plates + ')' : ''}`; document.getElementById('vehicleBanner').classList.add('visible'); } // Fetch full customer detail try { const detail = await api(`/pos/api/customers/${customer.id}`); if (detail.recent_purchases && detail.recent_purchases.length > 0) { const last = detail.recent_purchases[0]; const daysAgo = Math.floor((Date.now() - new Date(last.created_at).getTime()) / 86400000); const daysText = daysAgo === 0 ? 'hoy' : daysAgo === 1 ? 'hace 1 dia' : `hace ${daysAgo} dias`; document.getElementById('lastPurchaseInfo').textContent = `${fmt(last.total)} ${daysText}`; document.getElementById('vehicleBanner').classList.add('visible'); } } catch (e) { console.warn('Could not fetch customer detail:', e); } // Update CFDI hint const cfdiHint = document.getElementById('cfdiHint'); if (cfdiHint && customer.rfc) { cfdiHint.textContent = `RFC: ${customer.rfc}`; document.getElementById('cfdiCheck').checked = !!customer.rfc; } renderCart(); } function clearCustomer() { currentCustomer = null; document.getElementById('customerSelected').style.display = 'none'; const searchInput = document.getElementById('customerSearch'); if (searchInput) { searchInput.style.display = ''; searchInput.value = ''; } document.getElementById('vehicleBanner').classList.remove('visible'); const cfdiHint = document.getElementById('cfdiHint'); if (cfdiHint) cfdiHint.textContent = ''; renderCart(); } // ─── New Customer Modal ────────────── function showNewCustomerModal() { document.getElementById('newCustomerModal').classList.add('open'); setTimeout(() => document.getElementById('ncName').focus(), 100); } function closeNewCustomerModal() { document.getElementById('newCustomerModal').classList.remove('open'); } async function saveNewCustomer() { const name = document.getElementById('ncName').value.trim(); if (!name) { alert('Nombre es requerido'); return; } const vehicle_info = []; const make = document.getElementById('ncVehMake').value.trim(); if (make) { vehicle_info.push({ make: make, model: document.getElementById('ncVehModel').value.trim(), year: document.getElementById('ncVehYear').value.trim(), plates: document.getElementById('ncVehPlates').value.trim(), }); } const body = { name: name, rfc: document.getElementById('ncRfc').value.trim() || null, razon_social: document.getElementById('ncRazonSocial').value.trim() || null, regimen_fiscal: document.getElementById('ncRegimenFiscal').value || null, uso_cfdi: document.getElementById('ncUsoCfdi').value || 'G03', phone: document.getElementById('ncPhone').value.trim() || null, email: document.getElementById('ncEmail').value.trim() || null, price_tier: parseInt(document.getElementById('ncPriceTier').value) || 1, credit_limit: parseFloat(document.getElementById('ncCreditLimit').value) || 0, vehicle_info: vehicle_info.length > 0 ? vehicle_info : null, }; try { const result = await api('/pos/api/customers', { method: 'POST', body: JSON.stringify(body), }); selectCustomer({ id: result.id, name: body.name, rfc: body.rfc, phone: body.phone, price_tier: body.price_tier, credit_limit: body.credit_limit, credit_balance: 0, vehicle_info: body.vehicle_info, }); closeNewCustomerModal(); showToast('Cliente creado'); } catch (e) { alert('Error al crear cliente: ' + e.message); } } // ─── Payment ───────────────────────── function checkout() { if (cart.length === 0) { showToast('Carrito vacio'); return; } if (!currentRegister) { showOpenRegisterModal(); return; } paymentMethod = 'efectivo'; const total = getTotal(); // Populate modal document.getElementById('modalTotal').textContent = fmt(total); document.getElementById('modalItemCount').textContent = `${getItemCount()} productos`; document.getElementById('modalCustomerName').textContent = currentCustomer ? currentCustomer.name : 'Publico General'; // Reset inputs document.getElementById('cashReceived').value = ''; document.getElementById('changeDisplay').textContent = '$0.00'; document.getElementById('changeDisplay').className = 'cambio-amount positive'; const payRef = document.getElementById('paymentRef'); if (payRef) payRef.value = ''; // Populate quick amounts const quickContainer = document.getElementById('quickAmounts'); if (quickContainer) { const rounded = Math.ceil(total); const amounts = [rounded, Math.ceil(total / 100) * 100, Math.ceil(total / 500) * 500, Math.ceil(total / 1000) * 1000]; const unique = [...new Set(amounts)].slice(0, 4); quickContainer.innerHTML = unique.map(a => `` ).join(''); } // Set ref amount const refAmount = document.getElementById('refAmount'); if (refAmount) refAmount.value = fmt(total); // Reset payment tabs document.querySelectorAll('.pago-tab').forEach(t => t.classList.remove('active')); document.querySelector('.pago-tab[data-method="efectivo"]').classList.add('active'); document.getElementById('cashPayment').classList.add('active'); document.getElementById('cashPayment').style.display = ''; document.getElementById('refPayment').classList.remove('active'); document.getElementById('refPayment').style.display = 'none'; document.getElementById('mixedPayment').classList.remove('active'); document.getElementById('mixedPayment').style.display = 'none'; // Reset confirm button const confirmBtn = document.getElementById('btnConfirmPayment'); confirmBtn.disabled = false; confirmBtn.textContent = `Confirmar Pago — ${fmt(total)}`; document.getElementById('paymentModal').classList.add('open'); setTimeout(() => document.getElementById('cashReceived').focus(), 100); } function selectPaymentMethod(method, btn) { paymentMethod = method; // Update tabs document.querySelectorAll('.pago-tab').forEach(t => t.classList.remove('active')); if (btn) btn.classList.add('active'); // Show/hide tab content const tabs = { efectivo: 'cashPayment', transferencia: 'refPayment', tarjeta: 'refPayment', mixto: 'mixedPayment', credito: 'creditPayment', cheque: 'chequePayment', pendiente: 'pendingPayment', }; ['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => { const el = document.getElementById(id); if (el) { const isActive = el.id === tabs[method]; el.classList.toggle('active', isActive); el.style.display = isActive ? '' : 'none'; } }); if (method === 'efectivo') document.getElementById('cashReceived').focus(); if (method === 'transferencia' || method === 'tarjeta') { const ref = document.getElementById('paymentRef'); if (ref) ref.focus(); } if (method === 'cheque') { const chequeAmount = document.getElementById('chequeAmount'); if (chequeAmount) chequeAmount.value = fmt(getTotal()); const chequeRef = document.getElementById('chequeRef'); if (chequeRef) chequeRef.focus(); } } function updateChange() { const total = getTotal(); const received = parseFloat(document.getElementById('cashReceived').value) || 0; const change = received - total; const el = document.getElementById('changeDisplay'); if (change >= 0) { el.textContent = fmt(change); el.className = 'cambio-amount positive'; } else { el.textContent = '-' + fmt(Math.abs(change)); el.className = 'cambio-amount negative'; } } function updateMixedTotal() { const total = getTotal(); let sum = 0; document.querySelectorAll('.mixed-amount').forEach(input => { sum += parseFloat(input.value) || 0; }); const remaining = total - sum; const el = document.getElementById('mixedRemaining'); el.textContent = remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`; el.style.color = remaining > 0 ? 'var(--color-error)' : 'var(--color-success)'; } function closePaymentModal() { document.getElementById('paymentModal').classList.remove('open'); } async function confirmPayment() { const total = getTotal(); let amountPaid = 0; let paymentDetails = []; let reference = ''; let saleType = 'cash'; if (paymentMethod === 'efectivo') { amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0; if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; } } else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') { amountPaid = total; reference = document.getElementById('paymentRef').value.trim(); } else if (paymentMethod === 'mixto') { const rows = document.querySelectorAll('.mixed-row'); rows.forEach(row => { const method = row.querySelector('select').value; const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0; const ref = row.querySelectorAll('input')[1]?.value || ''; if (amount > 0) { paymentDetails.push({ method, amount, reference: ref }); amountPaid += amount; } }); if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; } } else if (paymentMethod === 'credito') { if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; } const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0); if (total > available) { alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`); return; } saleType = 'credit'; amountPaid = 0; } else if (paymentMethod === 'cheque') { amountPaid = total; reference = document.getElementById('chequeRef').value.trim(); } else if (paymentMethod === 'pendiente') { amountPaid = 0; } const saleData = { items: cart.map(item => ({ inventory_id: item.inventory_id, quantity: item.quantity, unit_price: item.unit_price, discount_pct: item.discount_pct, tax_rate: item.tax_rate, })), customer_id: currentCustomer ? currentCustomer.id : null, payment_method: paymentMethod, sale_type: saleType, register_id: currentRegister ? currentRegister.id : null, amount_paid: amountPaid, payment_details: paymentDetails, reference: reference, generate_cfdi: document.getElementById('cfdiCheck').checked, }; const zeroItem = findZeroPriceItem(); if (zeroItem) { showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); return; } const confirmBtn = document.getElementById('btnConfirmPayment'); confirmBtn.disabled = true; confirmBtn.textContent = 'Procesando...'; try { const convertQuoteId = localStorage.getItem('pos_convert_quote_id'); let sale; if (convertQuoteId) { const convertData = { register_id: currentRegister ? currentRegister.id : null, payment_method: paymentMethod, sale_type: saleType, amount_paid: amountPaid, payment_details: paymentDetails, reference: reference, }; sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', { method: 'POST', body: JSON.stringify(convertData), }); localStorage.removeItem('pos_convert_quote_id'); showToast(`Cotizacion #${convertQuoteId} convertida a venta #${sale.id}`); } else { sale = await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData), }); showToast(`Venta #${sale.id} completada`); } lastSaleId = sale.id; lastSaleData = sale; try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {} closePaymentModal(); showTicket(sale); // Clear cart cart = []; selectedRow = -1; clearCustomer(); renderCart(); } catch (e) { alert('Error al procesar venta: ' + e.message); } finally { confirmBtn.disabled = false; confirmBtn.textContent = 'Confirmar Pago'; } } // ─── Counter Remission Note ──────────── async function createRemissionNote() { if (cart.length === 0) { showToast('Carrito vacio'); return; } if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; } const zeroItem = findZeroPriceItem(); if (zeroItem) { showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); return; } const total = getTotal(); const noteData = { items: cart.map(item => ({ inventory_id: item.inventory_id, quantity: item.quantity, unit_price: item.unit_price, discount_pct: item.discount_pct, tax_rate: item.tax_rate, })), customer_id: currentCustomer ? currentCustomer.id : null, notes: 'Nota de remision generada desde mostrador', register_id: currentRegister ? currentRegister.id : null, courier_id: selectedCourierId, }; try { const sale = await api('/pos/api/sales/remission', { method: 'POST', body: JSON.stringify(noteData), }); if (selectedCourierId) { const courier = couriers.find(c => c.id === selectedCourierId); sale.courier_name = courier ? courier.name : ''; } lastSaleId = sale.id; lastSaleData = sale; try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {} showTicket(sale); cart = []; selectedRow = -1; clearCustomer(); selectedCourierId = null; const sel = document.getElementById('remissionCourier'); if (sel) sel.value = ''; renderCart(); showToast(`Nota de remision NR-${sale.id} generada`); } catch (e) { alert('Error al generar nota de remision: ' + e.message); } } async function openPayRemissionModal() { if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; } document.getElementById('payRemissionFolio').value = ''; document.getElementById('payRemissionDetail').innerHTML = ''; document.getElementById('payRemissionResult').innerHTML = ''; document.getElementById('payRemissionActions').style.display = 'none'; document.getElementById('payRemissionModal').classList.add('open'); setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100); } function closePayRemissionModal() { document.getElementById('payRemissionModal').classList.remove('open'); } let pendingRemissionToPay = null; async function searchRemissionToPay() { const folio = parseInt(document.getElementById('payRemissionFolio').value, 10); if (!folio) { showToast('Ingresa un folio valido'); return; } pendingRemissionToPay = null; try { const sale = await api('/pos/api/sales/' + folio); if (sale.status !== 'pending_payment') { document.getElementById('payRemissionDetail').innerHTML = `
La venta ${folio} no esta pendiente de pago
`; document.getElementById('payRemissionActions').style.display = 'none'; return; } pendingRemissionToPay = sale; let itemsHtml = (sale.items || []).map(it => `
${it.quantity} ${it.name || ''} ${fmt(it.subtotal || 0)}
`).join(''); document.getElementById('payRemissionDetail').innerHTML = `
Cliente:${sale.customer_name || 'Publico General'}
Vendedor:${sale.employee_name || ''}
Total:${fmt(sale.total)}

${itemsHtml} `; document.getElementById('payRemissionActions').style.display = ''; } catch (e) { document.getElementById('payRemissionDetail').innerHTML = `
${e.message}
`; document.getElementById('payRemissionActions').style.display = 'none'; } } async function confirmPayRemission() { if (!pendingRemissionToPay) return; const sale = pendingRemissionToPay; const paymentMethod = document.getElementById('payRemissionMethod').value; let amountPaid = parseFloat(sale.total); let paymentDetails = []; let reference = ''; if (paymentMethod === 'efectivo') { const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0; if (received < sale.total) { alert('Monto insuficiente'); return; } amountPaid = received; } else if (paymentMethod === 'mixto') { const rows = document.querySelectorAll('#payRemissionMixed .mixed-row'); let sum = 0; rows.forEach(row => { const method = row.querySelector('select').value; const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0; const ref = row.querySelectorAll('input')[1]?.value || ''; if (amount > 0) { paymentDetails.push({ method, amount, reference: ref }); sum += amount; } }); if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; } amountPaid = sum; } else { reference = document.getElementById('payRemissionReference').value.trim(); } try { const result = await api('/pos/api/sales/' + sale.id + '/pay', { method: 'POST', body: JSON.stringify({ payment_method: paymentMethod, amount_paid: amountPaid, payment_details: paymentDetails, register_id: currentRegister ? currentRegister.id : null, reference: reference, }), }); closePayRemissionModal(); showToast(`Nota NR-${sale.id} pagada`); // Refresh sale object to print paid ticket const updated = await api('/pos/api/sales/' + sale.id); lastSaleId = updated.id; lastSaleData = updated; showTicket(updated); } catch (e) { alert('Error al cobrar nota: ' + e.message); } } function updatePayRemissionMethod() { const method = document.getElementById('payRemissionMethod').value; const cashEl = document.getElementById('payRemissionCash'); const refEl = document.getElementById('payRemissionRef'); const mixedEl = document.getElementById('payRemissionMixed'); if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none'; if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none'; if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none'; } // ─── Quotation ─────────────────────── async function saveQuotation() { if (cart.length === 0) { showToast('Carrito vacio'); return; } const zeroItem = findZeroPriceItem(); if (zeroItem) { showToast('No está permitido cotizar artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); return; } const body = { items: cart.map(item => ({ inventory_id: item.inventory_id, quantity: item.quantity, unit_price: item.unit_price, discount_pct: item.discount_pct, tax_rate: item.tax_rate, })), customer_id: currentCustomer ? currentCustomer.id : null, }; const editQuoteId = localStorage.getItem('pos_edit_quote_id'); try { if (editQuoteId) { const result = await api('/pos/api/quotations/' + editQuoteId, { method: 'PUT', body: JSON.stringify(body), }); localStorage.removeItem('pos_edit_quote_id'); localStorage.removeItem('pos_edit_quote_customer_id'); localStorage.removeItem('pos_edit_quote_notes'); showToast(`Cotizacion #${editQuoteId} actualizada. Total: ${fmt(result.total)}`); } else { const result = await api('/pos/api/quotations', { method: 'POST', body: JSON.stringify(body), }); showToast(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}`); } } catch (e) { alert('Error: ' + e.message); } } // ─── Layaway ───────────────────────── async function createLayaway() { if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; } if (cart.length === 0) { alert('Carrito vacio'); return; } const zeroItem = findZeroPriceItem(); if (zeroItem) { alert('No está permitido apartar artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); return; } if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; } const total = getTotal(); const initialPayment = prompt(`Total: ${fmt(total)}\nIngrese monto del anticipo:`); if (!initialPayment) return; const amount = parseFloat(initialPayment); if (isNaN(amount) || amount <= 0) { alert('Monto invalido'); return; } if (amount > total) { alert('El anticipo no puede exceder el total'); return; } const body = { items: cart.map(item => ({ inventory_id: item.inventory_id, quantity: item.quantity, unit_price: item.unit_price, discount_pct: item.discount_pct, tax_rate: item.tax_rate, })), customer_id: currentCustomer.id, initial_payment: amount, payment_method: 'efectivo', register_id: currentRegister ? currentRegister.id : null, }; try { const result = await api('/pos/api/layaways', { method: 'POST', body: JSON.stringify(body), }); showToast(`Apartado #${result.id} creado. Restante: ${fmt(result.remaining)}`); cart = []; selectedRow = -1; clearCustomer(); renderCart(); } catch (e) { alert('Error: ' + e.message); } } // ─── Service Order from POS ────────── function createServiceOrder() { if (!workshopModuleEnabled) { showToast('El módulo de taller no está habilitado'); return; } if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; } if (cart.length === 0) { showToast('Carrito vacio'); return; } const zeroItem = findZeroPriceItem(); if (zeroItem) { showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number)); return; } const custInput = document.getElementById('soCustomer'); if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General'; document.getElementById('serviceOrderModal').classList.add('open'); } function closeServiceOrderModal() { document.getElementById('serviceOrderModal').classList.remove('open'); } async function confirmServiceOrder() { const customerId = currentCustomer ? currentCustomer.id : null; if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; } const vehicleText = document.getElementById('soVehicle').value.trim(); const delivery = document.getElementById('soDelivery').value; const notes = document.getElementById('soNotes').value.trim(); const isDirect = document.getElementById('soDirect').checked; const items = cart.map(item => ({ inventory_id: item.inventory_id, part_number: item.part_number, name: item.name, quantity: item.quantity, unit_price: item.unit_price, status: 'pending' })); try { const order = await api('/pos/api/service-orders/from-pos', { method: 'POST', body: JSON.stringify({ customer_id: customerId, priority: 'normal', reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''), delivery_method: delivery || null, is_direct: isDirect, items: items }) }); showToast(`Orden de servicio ${order.order_number} creada`); closeServiceOrderModal(); cart = []; selectedRow = -1; renderCart(); showServiceOrderTicket(order); } catch (e) { alert('Error: ' + e.message); } } function showServiceOrderTicket(order) { const dateStr = new Date(order.created_at).toLocaleString('es-MX', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General'); let itemsHtml = ''; (order.items || []).forEach(item => { const itemTotal = item.unit_price * item.quantity; itemsHtml += `
${item.quantity} ${item.name || ''} ${fmt(item.unit_price)} ${fmt(itemTotal)}
`; }); const ticketHtml = `
NEXUS AUTOPARTS
Orden de servicio
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}
RFC: NAU210315XX1

ORDEN: ${order.order_number} ${dateStr}
Cliente: ${customerName}

Cant Descripcion P. Unit Importe

${itemsHtml}
Refacciones:${fmt(order.total_parts || 0)}
Mano de obra:${fmt(order.total_labor || 0)}
TOTAL:${fmt(order.total || 0)}

`; const printArea = document.getElementById('ticketContent'); if (printArea) printArea.innerHTML = ticketHtml; const preview = document.getElementById('ticketPreviewContent'); if (preview) preview.innerHTML = ticketHtml; const modalHeader = document.querySelector('#ticketModal .modal-header h3'); if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio'; document.getElementById('ticketModal').classList.add('open'); } // ─── Ticket ────────────────────────── function showTicket(sale) { const dateStr = new Date(sale.created_at).toLocaleString('es-MX', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const isRemission = sale.status === 'pending_payment'; const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General'); const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : ''); let itemsHtml = ''; (sale.items || []).forEach(item => { const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100)); itemsHtml += `
${item.quantity} ${item.name || ''} ${fmt(item.unit_price)} ${fmt(item.subtotal || itemTotal)}
`; }); const cfg = receiptConfig || {}; const showLogo = cfg.show_logo && cfg.logo; const showRfc = cfg.show_rfc !== false; const showAddress = cfg.show_address; const showPhone = cfg.show_phone; const showIva = cfg.show_iva_breakdown !== false; const showPayment = cfg.show_payment_details !== false; const showEmployee = cfg.show_employee; const storeName = rc('store_name', 'NEXUS AUTOPARTS'); const storeTagline = rc('tagline', 'Tu conexion con las refacciones'); const storeRfc = rc('rfc', 'NAU210315XX1'); const storeAddress = rc('address', ''); const storePhone = rc('phone', ''); const thanksMsg = rc('thanks_message', 'Gracias por su compra!'); const footerMsg = rc('footer', 'Conserve su ticket como comprobante.'); let storeInfoLines = []; if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`); if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`); if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`); if (showAddress && storeAddress) storeInfoLines.push(storeAddress); if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`); const logoHtml = showLogo ? `
` : ''; const ticketHtml = ` ${logoHtml}
${storeName}
${storeTagline ? `
${storeTagline}
` : ''}
${storeInfoLines.join('
')}

${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id} ${dateStr}
Cliente: ${customerName} ${customerRfc ? `RFC: ${customerRfc}` : ''}
${isRemission && sale.courier_name ? `
Repartidor: ${sale.courier_name}
` : ''}
Cant Descripcion P. Unit Importe

${itemsHtml}
Subtotal:${fmt(sale.subtotal)}
${sale.discount_total > 0 ? `
Descuento:-${fmt(sale.discount_total)}
` : ''} ${showIva ? `
IVA:${fmt(sale.tax_total)}
` : ''}
TOTAL:${fmt(sale.total)}
${showPayment ? `
${isRemission ? `
Estado:PENDIENTE DE PAGO
Presente esta nota en caja para pagar
` : `
Forma de pago:${sale.payment_method || paymentMethod}
${sale.payment_method === 'efectivo' ? `
Recibido:${fmt(sale.amount_paid)}
Cambio:${fmt(sale.change_given || 0)}
` : ''} `}
` : ''}
`; // Set both print area and preview const printArea = document.getElementById('ticketContent'); if (printArea) printArea.innerHTML = ticketHtml; const preview = document.getElementById('ticketPreviewContent'); if (preview) preview.innerHTML = ticketHtml; const modalHeader = document.querySelector('#ticketModal .modal-header h3'); if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta'; document.getElementById('ticketModal').classList.add('open'); } function closeTicketModal() { document.getElementById('ticketModal').classList.remove('open'); } function printTicket() { const src = document.getElementById('ticketContent'); if (!src) return; // Paper width: 58 mm -> 48 mm printable; 80 mm -> 72 mm printable. let iframe = document.getElementById('ticketPrintFrame'); if (!iframe) { iframe = document.createElement('iframe'); iframe.id = 'ticketPrintFrame'; iframe.style.position = 'fixed'; iframe.style.left = '-9999px'; iframe.style.top = '0'; iframe.style.width = printWidth + 'mm'; iframe.style.border = '0'; document.body.appendChild(iframe); } const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80; const is58 = paperWidth === 58; // Render at the real printable width so text is not scaled down by the driver. const printWidth = is58 ? 48 : 72; const fontSize = is58 ? '12pt' : '14pt'; const smallFont = is58 ? '10pt' : '12pt'; const nameMax = is58 ? '20mm' : '38mm'; const doc = iframe.contentDocument || iframe.contentWindow.document; doc.open(); doc.write(` ${src.innerHTML} `); doc.close(); // Measure real content height and set exact page size (avoids phantom 3276 mm). const bodyEl = doc.body; const heightPx = Math.max(bodyEl.scrollHeight, bodyEl.offsetHeight); const heightMm = Math.ceil((heightPx * 25.4 / 96) + 5); const exactPage = doc.createElement('style'); exactPage.textContent = `@page { margin: 0; size: ${printWidth}mm ${heightMm}mm; }`; doc.head.appendChild(exactPage); iframe.contentWindow.focus(); setTimeout(() => { iframe.contentWindow.print(); }, 150); } // ─── Thermal Printing ───────────────── async function connectThermal() { if (!window.NexusPrinter) { showToast('Printer module not loaded'); return; } const result = await NexusPrinter.connect(); if (result.ok) { showToast('Impresora conectada: ' + (result.name || result.type)); _updatePrinterButtons(); } else { showToast(result.error || 'No se pudo conectar la impresora'); } } async function thermalPrint() { if (!window.NexusPrinter || !NexusPrinter.isConnected()) { showToast('Conecte una impresora termica primero'); return; } if (!lastSaleId) { showToast('No hay venta para imprimir'); return; } const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80; const ok = await NexusPrinter.printSale(lastSaleId, paperWidth); if (ok) { showToast('Ticket enviado a impresora termica'); } else { showToast('Error al imprimir. Reconecte la impresora.'); } } function _updatePrinterButtons() { const connectBtn = document.getElementById('btnConnectPrinter'); const thermalBtn = document.getElementById('btnThermalPrint'); if (window.NexusPrinter && NexusPrinter.isConnected()) { if (connectBtn) connectBtn.style.display = 'none'; if (thermalBtn) thermalBtn.style.display = ''; } else { if (connectBtn) connectBtn.style.display = ''; if (thermalBtn) thermalBtn.style.display = 'none'; } } // ─── Last Sale ─────────────────────── async function showLastSale() { if (!lastSaleId) { showToast('No hay venta reciente'); return; } try { const sale = await api(`/pos/api/sales/${lastSaleId}`); showTicket(sale); } catch (e) { alert('Error: ' + e.message); } } // ─── Drawer ────────────────────────── function openDrawer() { showToast('Comando enviado al cajon de efectivo.'); } // ─── Keyboard Shortcuts ────────────── function setupKeyboard() { document.addEventListener('keydown', (e) => { const tag = e.target.tagName; const inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; switch (e.key) { case 'F1': e.preventDefault(); document.getElementById('itemSearch').focus(); break; case 'F2': e.preventDefault(); document.getElementById('customerSearch').focus(); document.getElementById('customerSearch').style.display = ''; document.getElementById('customerSelected').style.display = 'none'; break; case 'F3': e.preventDefault(); checkout(); break; case 'F4': e.preventDefault(); saveQuotation(); break; case 'F5': e.preventDefault(); showLastSale(); break; case 'F6': e.preventDefault(); openDrawer(); break; case 'F7': e.preventDefault(); createServiceOrder(); break; case 'Escape': e.preventDefault(); if (document.getElementById('paymentModal').classList.contains('open')) { closePaymentModal(); } else if (document.getElementById('newCustomerModal').classList.contains('open')) { closeNewCustomerModal(); } else if (document.getElementById('ticketModal').classList.contains('open')) { closeTicketModal(); } else if (document.getElementById('serviceOrderModal').classList.contains('open')) { closeServiceOrderModal(); } else if (document.querySelector('.confirm-overlay.active')) { // Close cancel modal const overlay = document.getElementById('overlay-cancelar-venta'); const dialog = document.getElementById('modal-cancelar-venta'); if (overlay && overlay.classList.contains('active')) { overlay.classList.remove('active'); dialog.classList.remove('active'); } } else { hideSearchResults(); } break; case 'Delete': if (!inInput && selectedRow >= 0 && selectedRow < cart.length) { e.preventDefault(); removeFromCart(selectedRow); } break; case '+': if (!inInput && selectedRow >= 0 && selectedRow < cart.length) { e.preventDefault(); cart[selectedRow].quantity++; renderCart(); } break; case '-': if (!inInput && selectedRow >= 0 && selectedRow < cart.length) { e.preventDefault(); if (cart[selectedRow].quantity > 1) { cart[selectedRow].quantity--; renderCart(); } } break; case '*': if (!inInput && selectedRow >= 0 && selectedRow < cart.length) { e.preventDefault(); const disc = prompt('Descuento %:', cart[selectedRow].discount_pct); if (disc !== null) { updateDiscount(selectedRow, disc); } } break; case 'ArrowUp': if (!inInput && cart.length > 0) { e.preventDefault(); selectedRow = Math.max(0, selectedRow - 1); renderCart(); } break; case 'ArrowDown': if (!inInput && cart.length > 0) { e.preventDefault(); selectedRow = Math.min(cart.length - 1, selectedRow + 1); renderCart(); } break; case 'Enter': if (e.target.id === 'cashReceived') { e.preventDefault(); confirmPayment(); } break; } }); } // Register Cmd+K items if (typeof registerCmdKItem === "function") { registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" }); registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" }); registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" }); registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" }); } // ─── Public API ────────────────────── init(); return { addToCart, removeFromCart, clearCart, selectRow, updateQty, updateDiscount, addFromSearch, hideSearchResults, selectCustomer, clearCustomer, showNewCustomerModal, closeNewCustomerModal, saveNewCustomer, checkout, confirmPayment, closePaymentModal, selectPaymentMethod, updateChange, updateMixedTotal, createRemissionNote, openPayRemissionModal, closePayRemissionModal, searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod, saveQuotation, createLayaway, createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket, showLastSale, openDrawer, showTicket, closeTicketModal, printTicket, connectThermal, thermalPrint, showOpenRegisterModal, closeOpenRegisterModal, openRegister, showCutZModal, closeCutZModal, loadCutX, confirmCutZ, openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice, }; })();