// /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 = null; let lastSaleData = null; let searchTimeout = null; let customerSearchTimeout = 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 }); 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); } // ─── 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 || ''; canViewCost = (payload.permissions || []).includes('pos.view_cost'); employeeMaxDiscount = payload.max_discount_pct || 100; // 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(); } } catch (e) { console.warn('Could not parse token:', e); } // Load cart from localStorage (from catalog) const catalogCart = localStorage.getItem('pos_cart'); 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); } } // Load current register await loadRegister(); // 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 { document.getElementById('registerInfo').innerHTML = 'Sin caja abierta'; } } catch (e) { console.warn('Register check failed:', e); } } // ─── Cart ──────────────────────────── function addToCart(item) { const existing = cart.find(c => c.inventory_id === item.inventory_id); if (existing) { existing.quantity += (item.quantity || 1); renderCart(); 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, }); renderCart(); showToast(`${item.name || 'Articulo'} agregado`); } function removeFromCart(index) { cart.splice(index, 1); if (selectedRow >= cart.length) selectedRow = cart.length - 1; renderCart(); } 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
'; } else { let html = ''; data.data.forEach(item => { let price = item.price_1; if (currentCustomer) { const tier = currentCustomer.price_tier || 1; price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1; } 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, }); 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); } } async function selectCustomer(customer) { currentCustomer = customer; document.getElementById('customerAutocomplete').style.display = 'none'; document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none'; 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) { alert('No hay caja abierta. Abra una caja primero.'); 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', }; ['cashPayment', 'refPayment', 'mixedPayment'].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(); } } 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 = ''; 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; } } 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: 'cash', register_id: currentRegister ? currentRegister.id : null, amount_paid: amountPaid, payment_details: paymentDetails, reference: reference, generate_cfdi: document.getElementById('cfdiCheck').checked, }; const confirmBtn = document.getElementById('btnConfirmPayment'); confirmBtn.disabled = true; confirmBtn.textContent = 'Procesando...'; try { const sale = await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData), }); lastSaleId = sale.id; lastSaleData = sale; closePaymentModal(); showTicket(sale); // Clear cart cart = []; selectedRow = -1; clearCustomer(); renderCart(); showToast(`Venta #${sale.id} completada`); } catch (e) { alert('Error al procesar venta: ' + e.message); } finally { confirmBtn.disabled = false; confirmBtn.textContent = 'Confirmar Pago'; } } // ─── Credit Sale ───────────────────── async function creditSale() { if (cart.length === 0) { alert('Carrito vacio'); return; } if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; } if (!currentRegister) { alert('No hay caja abierta.'); return; } const total = getTotal(); const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0); if (currentCustomer.credit_limit > 0 && total > available) { if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) { return; } } 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.id, payment_method: 'credito', sale_type: 'credit', register_id: currentRegister ? currentRegister.id : null, amount_paid: 0, }; try { const sale = await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData), }); lastSaleId = sale.id; lastSaleData = sale; showTicket(sale); cart = []; selectedRow = -1; clearCustomer(); renderCart(); } catch (e) { alert('Error: ' + e.message); } } // ─── Quotation ─────────────────────── async function saveQuotation() { if (cart.length === 0) { showToast('Carrito vacio'); 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, }; try { 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 (cart.length === 0) { alert('Carrito vacio'); 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); } } // ─── 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 customerName = currentCustomer ? currentCustomer.name : 'Publico General'; const customerRfc = 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 ticketHtml = `
NEXUS AUTOPARTS
Tu conexion con las refacciones
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}
RFC: NAU210315XX1

VENTA: V-${sale.id} ${dateStr}
Cliente: ${customerName} ${customerRfc ? `RFC: ${customerRfc}` : ''}

Cant Descripcion P. Unit Importe

${itemsHtml}
Subtotal:${fmt(sale.subtotal)}
${sale.discount_total > 0 ? `
Descuento:-${fmt(sale.discount_total)}
` : ''}
IVA 16%:${fmt(sale.tax_total)}
TOTAL:${fmt(sale.total)}

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; document.getElementById('ticketModal').classList.add('open'); } function closeTicketModal() { document.getElementById('ticketModal').classList.remove('open'); } function printTicket() { // Make print area visible for @media print const area = document.getElementById('ticketPrintArea'); if (area) area.style.display = 'block'; window.print(); setTimeout(() => { if (area) area.style.display = 'none'; }, 500); } // ─── 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 ok = await NexusPrinter.printSale(lastSaleId); 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 '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.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; } }); } // ─── Public API ────────────────────── init(); return { addToCart, removeFromCart, selectRow, updateQty, updateDiscount, addFromSearch, hideSearchResults, selectCustomer, clearCustomer, showNewCustomerModal, closeNewCustomerModal, saveNewCustomer, checkout, confirmPayment, closePaymentModal, selectPaymentMethod, updateChange, updateMixedTotal, creditSale, saveQuotation, createLayaway, showLastSale, openDrawer, showTicket, closeTicketModal, printTicket, connectThermal, thermalPrint, }; })();