From b2484af0fb52f80a9384e6c5da5effedb665510f Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 31 Mar 2026 03:38:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20add=20POS=20sale=20page=20?= =?UTF-8?q?=E2=80=94=20F-keys,=20payment=20modal,=20margin=20display,=20ti?= =?UTF-8?q?cket=20print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/static/js/pos.js | 928 +++++++++++++++++++++++++++++++++++++++++ pos/templates/pos.html | 403 ++++++++++++++++++ 2 files changed, 1331 insertions(+) create mode 100644 pos/static/js/pos.js create mode 100644 pos/templates/pos.html diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js new file mode 100644 index 0000000..7cc742b --- /dev/null +++ b/pos/static/js/pos.js @@ -0,0 +1,928 @@ +// /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, + }; +})(); diff --git a/pos/templates/pos.html b/pos/templates/pos.html new file mode 100644 index 0000000..6a20d20 --- /dev/null +++ b/pos/templates/pos.html @@ -0,0 +1,403 @@ + + + + + + + Punto de Venta - Nexus POS + + + + + +
+
+ Cargando... + +
+
+ Caja: -- +
+
F1=Buscar F2=Cliente F3=Cobrar F4=Cotizacion F5=Ult.Venta
+
+ + +
+ +
+ +
+ Cliente: +
+ + +
+ + +
+ + +
+ Vehiculo: + + +
+ + + + + +
+
+
Carrito vacio
Busca productos o presiona F1
+
+ + + + + + + + + + + + + + + + +
+
+ + +
+ +
+ + +
+
+
Subtotal:$0.00
+ +
IVA (16%):$0.00
+
TOTAL:$0.00
+
+
+ + + % +
+
+ + +
+ + + + + + +
+
+
+ + +
+ F1Buscar + F2Cliente + F3Cobrar + F4Cotizacion + F5Ult.Venta + F6Cajon + +/-Cantidad + *Descuento + EnterAgregar + DelEliminar +
+ + + + + + + + + + + + +