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
+
+
+
+
+ | # |
+ Producto |
+ Cant |
+ Precio |
+ Desc% |
+ Subtotal |
+ Costo |
+ Margen |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Subtotal:$0.00
+
+ Descuento:-$0.00
+
+
IVA (16%):$0.00
+
TOTAL:$0.00
+
+
+
+
+ %
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ F1Buscar
+ F2Cliente
+ F3Cobrar
+ F4Cotizacion
+ F5Ult.Venta
+ F6Cajon
+ +/-Cantidad
+ *Descuento
+ EnterAgregar
+ DelEliminar
+
+
+
+
+
+
Cobrar Venta
+
$0.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Cambio: $0.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nuevo Cliente
+
+
+
+
+
+
+
+
+
+
+
+
Venta Completada
+
+
+
+
+
+
+
+
+
+
+