// /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 or quotation edit/convert)
const catalogCart = localStorage.getItem('pos_cart');
const editQuoteId = localStorage.getItem('pos_edit_quote_id');
const convertQuoteId = localStorage.getItem('pos_convert_quote_id');
if (catalogCart) {
try {
const items = JSON.parse(catalogCart);
for (const item of items) {
addToCart(item);
}
localStorage.removeItem('pos_cart');
} catch (e) { console.warn('Could not load catalog cart:', e); }
}
if (editQuoteId) {
showToast(`Modo edicion: Cotizacion #${editQuoteId}. Guardar actualizara la cotizacion.`);
}
if (convertQuoteId) {
showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`);
}
// Load current register
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 {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'⚠ Sin caja abierta — Clic para abrir';
}
} catch (e) {
console.warn('Register check failed:', e);
}
}
function showOpenRegisterModal() {
document.getElementById('openRegisterModal').classList.add('open');
document.getElementById('registerOpenResult').innerHTML = '';
}
function closeOpenRegisterModal() {
document.getElementById('openRegisterModal').classList.remove('open');
document.getElementById('registerOpenResult').innerHTML = '';
}
async function openRegister() {
const number = parseInt(document.getElementById('regNumber').value);
const amount = parseFloat(document.getElementById('regOpeningAmount').value) || 0;
if (!number || number < 1) {
document.getElementById('registerOpenResult').innerHTML = 'Numero de caja invalido';
return;
}
try {
const data = await api('/pos/api/register/open', {
method: 'POST',
body: JSON.stringify({ register_number: number, opening_amount: amount })
});
if (data.error) {
document.getElementById('registerOpenResult').innerHTML = '' + (data.error || 'Error') + '';
return;
}
currentRegister = data;
document.getElementById('registerInfo').innerHTML = `Caja #${data.register_number}`;
closeOpenRegisterModal();
showToast(`Caja #${data.register_number} abierta con $${amount.toFixed(2)}`);
} catch (e) {
document.getElementById('registerOpenResult').innerHTML = 'Error de red';
}
}
// ─── Cut X / Z (Close Register) ──────
function showCutZModal() {
document.getElementById('cutZModal').classList.add('open');
document.getElementById('cutZResult').innerHTML = '';
loadCutX();
}
function closeCutZModal() {
document.getElementById('cutZModal').classList.remove('open');
document.getElementById('cutZResult').innerHTML = '';
}
async function loadCutX() {
const el = document.getElementById('cutZSummary');
try {
const data = await api('/pos/api/register/cut-x');
if (data.error) {
el.innerHTML = '
' + data.error + '
';
return;
}
let html = '';
html += '
Efectivo inicial
' + fmt(data.opening_amount) + '
';
html += '
Ventas totales
' + fmt(data.total_sales) + ' (' + data.total_sales_count + ')
';
html += '
Efectivo en ventas
' + fmt(data.cash_from_sales) + '
';
html += '
Cambio entregado
-' + fmt(data.change_given) + '
';
html += '
Entradas de efectivo
+' + fmt(data.cash_movements_in) + '
';
html += '
Salidas de efectivo
-' + fmt(data.cash_movements_out) + '
';
html += '
Cancelaciones
' + data.cancelled_count + ' (' + fmt(data.cancelled_amount) + ')
';
html += '
Efectivo esperado
' + fmt(data.expected_cash) + '
';
html += '
';
if (data.sales_by_method && Object.keys(data.sales_by_method).length) {
html += '';
html += 'Por metodo de pago:
';
for (const [method, info] of Object.entries(data.sales_by_method)) {
html += '' + method + ': ' + fmt(info.amount) + ' (' + info.count + ')';
}
html += '
';
}
if (data.movement_detail && data.movement_detail.length) {
html += '';
html += '
Movimientos de caja:';
data.movement_detail.forEach(function(m) {
html += '
' + m.type + ' ' + fmt(m.amount) + ' — ' + (m.reason || '') + '
';
});
html += '
';
}
el.innerHTML = html;
document.getElementById('cutZClosingAmount').value = data.expected_cash.toFixed(2);
} catch (e) {
el.innerHTML = 'Error cargando resumen
';
}
}
async function confirmCutZ() {
const amount = parseFloat(document.getElementById('cutZClosingAmount').value) || 0;
try {
const data = await api('/pos/api/register/cut-z', {
method: 'POST',
body: JSON.stringify({ closing_amount: amount })
});
if (data.error) {
document.getElementById('cutZResult').innerHTML = '' + data.error + '';
return;
}
const diffColor = data.difference > 0 ? 'var(--color-success)' : (data.difference < 0 ? 'var(--color-error)' : 'var(--color-text-muted)');
document.getElementById('cutZResult').innerHTML = 'Caja cerrada correctamente';
document.getElementById('registerInfo').innerHTML = 'Sin caja abierta';
currentRegister = null;
closeCutZModal();
showToast('Corte Z completado. Diferencia: $' + data.difference.toFixed(2));
} catch (e) {
document.getElementById('cutZResult').innerHTML = 'Error de red';
}
}
// ─── Cart ────────────────────────────
function addToCart(item) {
const existing = cart.find(c => c.inventory_id === item.inventory_id);
if (existing) {
existing.quantity += (item.quantity || 1);
renderCart();
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) { showOpenRegisterModal(); return; }
paymentMethod = 'efectivo';
const total = getTotal();
// Populate modal
document.getElementById('modalTotal').textContent = fmt(total);
document.getElementById('modalItemCount').textContent = `${getItemCount()} productos`;
document.getElementById('modalCustomerName').textContent =
currentCustomer ? currentCustomer.name : 'Publico General';
// Reset inputs
document.getElementById('cashReceived').value = '';
document.getElementById('changeDisplay').textContent = '$0.00';
document.getElementById('changeDisplay').className = 'cambio-amount positive';
const payRef = document.getElementById('paymentRef');
if (payRef) payRef.value = '';
// Populate quick amounts
const quickContainer = document.getElementById('quickAmounts');
if (quickContainer) {
const rounded = Math.ceil(total);
const amounts = [rounded, Math.ceil(total / 100) * 100, Math.ceil(total / 500) * 500, Math.ceil(total / 1000) * 1000];
const unique = [...new Set(amounts)].slice(0, 4);
quickContainer.innerHTML = unique.map(a =>
``
).join('');
}
// Set ref amount
const refAmount = document.getElementById('refAmount');
if (refAmount) refAmount.value = fmt(total);
// Reset payment tabs
document.querySelectorAll('.pago-tab').forEach(t => t.classList.remove('active'));
document.querySelector('.pago-tab[data-method="efectivo"]').classList.add('active');
document.getElementById('cashPayment').classList.add('active');
document.getElementById('cashPayment').style.display = '';
document.getElementById('refPayment').classList.remove('active');
document.getElementById('refPayment').style.display = 'none';
document.getElementById('mixedPayment').classList.remove('active');
document.getElementById('mixedPayment').style.display = 'none';
// Reset confirm button
const confirmBtn = document.getElementById('btnConfirmPayment');
confirmBtn.disabled = false;
confirmBtn.textContent = `Confirmar Pago — ${fmt(total)}`;
document.getElementById('paymentModal').classList.add('open');
setTimeout(() => document.getElementById('cashReceived').focus(), 100);
}
function selectPaymentMethod(method, btn) {
paymentMethod = method;
// Update tabs
document.querySelectorAll('.pago-tab').forEach(t => t.classList.remove('active'));
if (btn) btn.classList.add('active');
// Show/hide tab content
const tabs = {
efectivo: 'cashPayment',
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
};
['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 convertQuoteId = localStorage.getItem('pos_convert_quote_id');
let sale;
if (convertQuoteId) {
const convertData = {
register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
amount_paid: amountPaid,
payment_details: paymentDetails,
};
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST',
body: JSON.stringify(convertData),
});
localStorage.removeItem('pos_convert_quote_id');
showToast(`Cotizacion #${convertQuoteId} convertida a venta #${sale.id}`);
} else {
sale = await api('/pos/api/sales', {
method: 'POST',
body: JSON.stringify(saleData),
});
showToast(`Venta #${sale.id} completada`);
}
lastSaleId = sale.id;
lastSaleData = sale;
closePaymentModal();
showTicket(sale);
// Clear cart
cart = [];
selectedRow = -1;
clearCustomer();
renderCart();
} catch (e) {
alert('Error al procesar venta: ' + e.message);
} finally {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Confirmar Pago';
}
}
// ─── 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,
};
const editQuoteId = localStorage.getItem('pos_edit_quote_id');
try {
if (editQuoteId) {
const result = await api('/pos/api/quotations/' + editQuoteId, {
method: 'PUT',
body: JSON.stringify(body),
});
localStorage.removeItem('pos_edit_quote_id');
localStorage.removeItem('pos_edit_quote_customer_id');
localStorage.removeItem('pos_edit_quote_notes');
showToast(`Cotizacion #${editQuoteId} actualizada. Total: ${fmt(result.total)}`);
} else {
const result = await api('/pos/api/quotations', {
method: 'POST',
body: JSON.stringify(body),
});
showToast(`Cotizacion #${result.id} guardada. Total: ${fmt(result.total)}`);
}
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Layaway ─────────────────────────
async function createLayaway() {
if (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,
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
};
})();