// /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 searchTimeout = null; let customerSearchTimeout = null; const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { 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; } // ─── 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 if permission if (canViewCost) { document.getElementById('thCost').style.display = ''; document.getElementById('thMargin').style.display = ''; } } 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}`; document.getElementById('registerInfo').classList.remove('no-register'); } else { document.getElementById('registerInfo').innerHTML = 'Sin caja abierta'; document.getElementById('registerInfo').classList.add('no-register'); } } catch (e) { console.warn('Register check failed:', e); } } // ─── Cart ──────────────────────────── function addToCart(item) { // Check if item already in cart 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(); } 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 cls = parseFloat(marginPct) < 5 ? 'margin-warning' : 'margin-info'; 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(); } 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 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; } // ─── Search ────────────────────────── function setupSearch() { const input = document.getElementById('itemSearch'); 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 => { // Determine price for current customer 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; } const stockClass = item.stock <= 0 ? 'zero' : ''; html += `
${item.name}
${item.part_number} | ${item.brand || ''}
Stock: ${item.stock}
${fmt(price)}
`; }); container.innerHTML = html; } container.classList.add('active'); totals.classList.add('hidden'); } 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() { document.getElementById('searchResults').classList.remove('active'); document.getElementById('totalsPanel').classList.remove('hidden'); } // ─── Customer Search ───────────────── function setupCustomerSearch() { const input = document.getElementById('customerSearch'); 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 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 to get recent purchase info 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 = `Ultima compra: ${fmt(last.total)} ${daysText}`; document.getElementById('vehicleBanner').classList.add('visible'); } } catch (e) { console.warn('Could not fetch customer detail:', e); } // Re-apply prices based on customer tier cart.forEach(item => { // Fetch updated price for this customer tier (would need to re-query) // For now, prices stay as-is (they were set at add time) }); renderCart(); } function clearCustomer() { currentCustomer = null; document.getElementById('customerSelected').style.display = 'none'; document.getElementById('customerSearchWrap').querySelector('input').style.display = ''; document.getElementById('customerSearchWrap').querySelector('input').value = ''; document.getElementById('vehicleBanner').classList.remove('visible'); renderCart(); } // ─── New Customer Modal ────────────── function showNewCustomerModal() { document.getElementById('newCustomerModal').classList.add('active'); document.getElementById('ncName').focus(); } function closeNewCustomerModal() { document.getElementById('newCustomerModal').classList.remove('active'); } 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), }); // Select the new customer 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(); } catch (e) { alert('Error al crear cliente: ' + e.message); } } // ─── Payment ───────────────────────── function checkout() { if (cart.length === 0) { alert('Carrito vacio'); return; } if (!currentRegister) { alert('No hay caja abierta. Abra una caja primero.'); return; } paymentMethod = 'efectivo'; const total = getTotal(); document.getElementById('modalTotal').textContent = fmt(total); document.getElementById('cashReceived').value = ''; document.getElementById('changeDisplay').textContent = 'Cambio: $0.00'; document.getElementById('changeDisplay').className = 'change-display positive'; document.getElementById('paymentRef').value = ''; // Reset payment method buttons document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active')); document.querySelector('.pm-btn[data-method="efectivo"]').classList.add('active'); document.getElementById('cashPayment').style.display = ''; document.getElementById('refPayment').style.display = 'none'; document.getElementById('mixedPayment').style.display = 'none'; document.getElementById('paymentModal').classList.add('active'); setTimeout(() => document.getElementById('cashReceived').focus(), 100); } function selectPaymentMethod(method, btn) { paymentMethod = method; document.querySelectorAll('.pm-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.getElementById('cashPayment').style.display = method === 'efectivo' ? '' : 'none'; document.getElementById('refPayment').style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none'; document.getElementById('mixedPayment').style.display = method === 'mixto' ? '' : 'none'; if (method === 'efectivo') document.getElementById('cashReceived').focus(); if (method === 'transferencia' || method === 'tarjeta') document.getElementById('paymentRef').focus(); } function updateChange() { const total = getTotal(); const received = parseFloat(document.getElementById('cashReceived').value) || 0; const change = received - total; const el = document.getElementById('changeDisplay'); el.textContent = `Cambio: ${fmt(Math.abs(change))}`; el.className = 'change-display ' + (change >= 0 ? 'positive' : 'negative'); } function updateMixedTotal() { const total = getTotal(); let sum = 0; document.querySelectorAll('.mixed-amount').forEach(input => { sum += parseFloat(input.value) || 0; }); const remaining = total - sum; document.getElementById('mixedRemaining').textContent = remaining > 0 ? `Faltante: ${fmt(remaining)}` : `Cubierto (${fmt(sum)})`; document.getElementById('mixedRemaining').style.color = remaining > 0 ? '#c62828' : '#2e7d32'; } function closePaymentModal() { document.getElementById('paymentModal').classList.remove('active'); } 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, }; document.getElementById('btnConfirmPayment').disabled = true; document.getElementById('btnConfirmPayment').textContent = 'Procesando...'; try { const sale = await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData), }); lastSaleId = sale.id; closePaymentModal(); showTicket(sale); // Clear cart cart = []; selectedRow = -1; clearCustomer(); renderCart(); } catch (e) { alert('Error al procesar venta: ' + e.message); } finally { document.getElementById('btnConfirmPayment').disabled = false; document.getElementById('btnConfirmPayment').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 de todas formas?`)) { 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; showTicket(sale); cart = []; selectedRow = -1; clearCustomer(); renderCart(); } catch (e) { alert('Error: ' + e.message); } } // ─── Quotation ─────────────────────── async function saveQuotation() { if (cart.length === 0) { alert('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), }); alert(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}\nValida hasta: ${result.valid_until}`); } 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), }); alert( `Apartado #${result.id} creado.\n` + `Total: ${fmt(result.total)}\n` + `Anticipo: ${fmt(result.amount_paid)}\n` + `Restante: ${fmt(result.remaining)}\n` + `Vence: ${result.expires_at}` ); cart = []; selectedRow = -1; clearCustomer(); renderCart(); } catch (e) { alert('Error: ' + e.message); } } // ─── Ticket ────────────────────────── function showTicket(sale) { const lines = []; lines.push('========================================'); lines.push(' NEXUS POS'); lines.push('========================================'); lines.push(`Venta #${sale.id}`); lines.push(`Fecha: ${new Date(sale.created_at).toLocaleString('es-MX')}`); if (currentCustomer) { lines.push(`Cliente: ${currentCustomer.name}`); if (currentCustomer.rfc) lines.push(`RFC: ${currentCustomer.rfc}`); } else { lines.push('Cliente: Publico General'); } lines.push('----------------------------------------'); (sale.items || []).forEach(item => { lines.push(`${item.name}`); let line = ` ${item.quantity} x ${fmt(item.unit_price)}`; if (item.discount_pct > 0) line += ` (-${item.discount_pct}%)`; line += ` ${fmt(item.subtotal)}`; lines.push(line); }); lines.push('----------------------------------------'); lines.push(`Subtotal: ${fmt(sale.subtotal).padStart(12)}`); if (sale.discount_total > 0) { lines.push(`Descuento: -${fmt(sale.discount_total).padStart(12)}`); } lines.push(`IVA: ${fmt(sale.tax_total).padStart(12)}`); lines.push('========================================'); lines.push(`TOTAL: ${fmt(sale.total).padStart(12)}`); lines.push('========================================'); if (sale.payment_method === 'efectivo') { lines.push(`Efectivo: ${fmt(sale.amount_paid).padStart(12)}`); lines.push(`Cambio: ${fmt(sale.change_given).padStart(12)}`); } else { lines.push(`Pago: ${sale.payment_method}`); } lines.push(''); lines.push(' Gracias por su compra!'); lines.push(''); document.getElementById('ticketPreview').textContent = lines.join('\n'); document.getElementById('ticketModal').classList.add('active'); } function closeTicketModal() { document.getElementById('ticketModal').classList.remove('active'); } function printTicket() { window.print(); } // ─── Last Sale ─────────────────────── async function showLastSale() { if (!lastSaleId) { alert('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() { // Cash drawer open command (ESC/POS compatible) // In a real implementation, this would send the command to the printer alert('Comando enviado al cajon de efectivo.'); } // ─── Keyboard Shortcuts ────────────── function setupKeyboard() { document.addEventListener('keydown', (e) => { // Don't intercept when typing in inputs 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('active')) { closePaymentModal(); } else if (document.getElementById('newCustomerModal').classList.contains('active')) { closeNewCustomerModal(); } else if (document.getElementById('ticketModal').classList.contains('active')) { closeTicketModal(); } 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, }; })();