414 lines
17 KiB
JavaScript
414 lines
17 KiB
JavaScript
/**
|
|
* pos.js — Point of Sale logic for Nexus Autoparts
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var API = '';
|
|
var selectedCustomer = null;
|
|
var cart = [];
|
|
var defaultMargin = 30;
|
|
|
|
// ================================================================
|
|
// Utility
|
|
// ================================================================
|
|
|
|
function toast(msg, type) {
|
|
var el = document.createElement('div');
|
|
el.className = 'toast ' + (type || 'success');
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
setTimeout(function () { el.remove(); }, 3000);
|
|
}
|
|
|
|
function api(path, opts) {
|
|
opts = opts || {};
|
|
return fetch(API + path, opts).then(function (r) {
|
|
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
|
return r.json();
|
|
});
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return '';
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function fmt(n) {
|
|
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
}
|
|
|
|
// ================================================================
|
|
// Customer Selection
|
|
// ================================================================
|
|
|
|
var customerSearchTimer = null;
|
|
var customerSearchEl = document.getElementById('customer-search');
|
|
var customerDropdown = document.getElementById('customer-dropdown');
|
|
|
|
customerSearchEl.addEventListener('input', function () {
|
|
clearTimeout(customerSearchTimer);
|
|
var q = this.value.trim();
|
|
if (q.length < 2) { customerDropdown.style.display = 'none'; return; }
|
|
customerSearchTimer = setTimeout(function () {
|
|
api('/api/pos/customers?search=' + encodeURIComponent(q) + '&per_page=10')
|
|
.then(function (res) {
|
|
var data = res.data || [];
|
|
if (data.length === 0) {
|
|
customerDropdown.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron clientes</div>';
|
|
} else {
|
|
customerDropdown.innerHTML = data.map(function (c) {
|
|
return '<div class="customer-dropdown-item" data-id="' + c.id_customer + '">' +
|
|
'<div><span class="cdi-name">' + esc(c.name) + '</span>' +
|
|
(c.rfc ? ' <span class="cdi-rfc">' + esc(c.rfc) + '</span>' : '') + '</div>' +
|
|
'<span style="font-size:0.8rem;color:' + (c.balance > 0 ? 'var(--danger)' : 'var(--success)') + '">' +
|
|
fmt(c.balance) + '</span></div>';
|
|
}).join('');
|
|
|
|
customerDropdown.querySelectorAll('.customer-dropdown-item').forEach(function (item) {
|
|
item.addEventListener('click', function () {
|
|
selectCustomer(parseInt(item.getAttribute('data-id')));
|
|
});
|
|
});
|
|
}
|
|
customerDropdown.style.display = 'block';
|
|
});
|
|
}, 300);
|
|
});
|
|
|
|
customerSearchEl.addEventListener('blur', function () {
|
|
setTimeout(function () { customerDropdown.style.display = 'none'; }, 200);
|
|
});
|
|
|
|
function selectCustomer(id) {
|
|
api('/api/pos/customers/' + id).then(function (c) {
|
|
selectedCustomer = c;
|
|
document.getElementById('customer-select').style.display = 'none';
|
|
var info = document.getElementById('customer-info');
|
|
info.style.display = 'flex';
|
|
document.getElementById('sel-customer-name').textContent = c.name;
|
|
document.getElementById('sel-customer-rfc').textContent = c.rfc || 'Sin RFC';
|
|
var balEl = document.getElementById('sel-customer-balance');
|
|
balEl.textContent = 'Saldo: ' + fmt(c.balance);
|
|
balEl.className = 'cb-balance ' + (c.balance > 0 ? 'positive' : 'zero');
|
|
customerDropdown.style.display = 'none';
|
|
updateFacturarBtn();
|
|
});
|
|
}
|
|
|
|
document.getElementById('btn-change-customer').addEventListener('click', function () {
|
|
selectedCustomer = null;
|
|
document.getElementById('customer-info').style.display = 'none';
|
|
document.getElementById('customer-select').style.display = 'block';
|
|
customerSearchEl.value = '';
|
|
customerSearchEl.focus();
|
|
updateFacturarBtn();
|
|
});
|
|
|
|
// ================================================================
|
|
// New Customer Modal
|
|
// ================================================================
|
|
|
|
document.getElementById('btn-new-customer').addEventListener('click', function () {
|
|
document.getElementById('modal-new-customer').style.display = 'flex';
|
|
document.getElementById('nc-name').focus();
|
|
});
|
|
|
|
document.getElementById('nc-cancel').addEventListener('click', function () {
|
|
document.getElementById('modal-new-customer').style.display = 'none';
|
|
});
|
|
|
|
document.getElementById('nc-save').addEventListener('click', function () {
|
|
var name = document.getElementById('nc-name').value.trim();
|
|
if (!name) { toast('Ingresa el nombre del cliente', 'error'); return; }
|
|
|
|
api('/api/pos/customers', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
rfc: document.getElementById('nc-rfc').value.trim() || null,
|
|
business_name: document.getElementById('nc-business').value.trim() || null,
|
|
phone: document.getElementById('nc-phone').value.trim() || null,
|
|
email: document.getElementById('nc-email').value.trim() || null,
|
|
address: document.getElementById('nc-address').value.trim() || null,
|
|
credit_limit: parseFloat(document.getElementById('nc-credit').value) || 0,
|
|
payment_terms: parseInt(document.getElementById('nc-terms').value) || 30
|
|
})
|
|
}).then(function (res) {
|
|
toast('Cliente creado: ' + name);
|
|
document.getElementById('modal-new-customer').style.display = 'none';
|
|
selectCustomer(res.id);
|
|
// Clear form
|
|
['nc-name','nc-rfc','nc-business','nc-phone','nc-email','nc-address'].forEach(function(id) {
|
|
document.getElementById(id).value = '';
|
|
});
|
|
document.getElementById('nc-credit').value = '0';
|
|
document.getElementById('nc-terms').value = '30';
|
|
}).catch(function (err) {
|
|
toast(err.message, 'error');
|
|
});
|
|
});
|
|
|
|
// ================================================================
|
|
// Part Search — Autocomplete
|
|
// ================================================================
|
|
|
|
var partSearchTimer = null;
|
|
var partSearchEl = document.getElementById('part-search');
|
|
var partResults = document.getElementById('part-results');
|
|
var searchResults = [];
|
|
var highlightIdx = -1;
|
|
|
|
function doPartSearch() {
|
|
var q = partSearchEl.value.trim();
|
|
if (q.length < 1) { partResults.style.display = 'none'; searchResults = []; return; }
|
|
clearTimeout(partSearchTimer);
|
|
partSearchTimer = setTimeout(function () {
|
|
api('/api/pos/search-parts?q=' + encodeURIComponent(q)).then(function (results) {
|
|
searchResults = results;
|
|
highlightIdx = -1;
|
|
renderSearchResults();
|
|
});
|
|
}, 150);
|
|
}
|
|
|
|
function renderSearchResults() {
|
|
if (searchResults.length === 0 && partSearchEl.value.trim().length > 0) {
|
|
partResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron partes para "' + esc(partSearchEl.value) + '"</div>';
|
|
partResults.style.display = 'block';
|
|
return;
|
|
}
|
|
if (searchResults.length === 0) { partResults.style.display = 'none'; return; }
|
|
|
|
partResults.innerHTML = searchResults.map(function (p, i) {
|
|
var active = i === highlightIdx ? ' part-result-active' : '';
|
|
return '<div class="part-result-item' + active + '" data-idx="' + i + '">' +
|
|
'<div><span class="pri-number">' + esc(p.oem_part_number) + '</span>' +
|
|
'<span class="pri-name">' + esc(p.name_part) + '</span></div>' +
|
|
'<div style="display:flex;align-items:center;gap:0.4rem">' +
|
|
'<span class="pri-type ' + p.part_type + '">' + p.part_type + '</span>' +
|
|
(p.cost_usd ? '<span style="font-size:0.8rem;color:var(--text-secondary)">' + fmt(p.cost_usd) + '</span>' : '') +
|
|
'<span style="font-size:0.75rem;color:var(--text-secondary)">' + esc(p.group_name || '') + '</span>' +
|
|
'</div></div>';
|
|
}).join('');
|
|
|
|
partResults.querySelectorAll('.part-result-item').forEach(function (item) {
|
|
item.addEventListener('mousedown', function (e) {
|
|
e.preventDefault();
|
|
selectSearchResult(parseInt(item.getAttribute('data-idx')));
|
|
});
|
|
item.addEventListener('mouseenter', function () {
|
|
highlightIdx = parseInt(item.getAttribute('data-idx'));
|
|
updateHighlight();
|
|
});
|
|
});
|
|
|
|
partResults.style.display = 'block';
|
|
}
|
|
|
|
function updateHighlight() {
|
|
partResults.querySelectorAll('.part-result-item').forEach(function (el, i) {
|
|
if (i === highlightIdx) {
|
|
el.classList.add('part-result-active');
|
|
el.scrollIntoView({ block: 'nearest' });
|
|
} else {
|
|
el.classList.remove('part-result-active');
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectSearchResult(idx) {
|
|
if (idx >= 0 && idx < searchResults.length) {
|
|
addToCart(searchResults[idx]);
|
|
partSearchEl.value = '';
|
|
partResults.style.display = 'none';
|
|
searchResults = [];
|
|
highlightIdx = -1;
|
|
partSearchEl.focus();
|
|
}
|
|
}
|
|
|
|
partSearchEl.addEventListener('input', doPartSearch);
|
|
|
|
partSearchEl.addEventListener('keydown', function (e) {
|
|
if (partResults.style.display === 'none' || searchResults.length === 0) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
highlightIdx = Math.min(highlightIdx + 1, searchResults.length - 1);
|
|
updateHighlight();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
|
updateHighlight();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (highlightIdx >= 0) {
|
|
selectSearchResult(highlightIdx);
|
|
} else if (searchResults.length === 1) {
|
|
selectSearchResult(0);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
partResults.style.display = 'none';
|
|
highlightIdx = -1;
|
|
}
|
|
});
|
|
|
|
partSearchEl.addEventListener('focus', function () {
|
|
if (searchResults.length > 0) {
|
|
partResults.style.display = 'block';
|
|
}
|
|
});
|
|
|
|
partSearchEl.addEventListener('blur', function () {
|
|
setTimeout(function () { partResults.style.display = 'none'; }, 200);
|
|
});
|
|
|
|
// ================================================================
|
|
// Cart
|
|
// ================================================================
|
|
|
|
function addToCart(part) {
|
|
cart.push({
|
|
part_id: part.part_type === 'oem' ? part.id_part : null,
|
|
aftermarket_id: part.part_type === 'aftermarket' ? part.id_part : null,
|
|
description: (part.oem_part_number || '') + ' - ' + (part.name_part || ''),
|
|
part_type: part.part_type,
|
|
quantity: 1,
|
|
unit_cost: part.cost_usd || 0,
|
|
margin_pct: defaultMargin,
|
|
unit_price: (part.cost_usd || 0) * (1 + defaultMargin / 100)
|
|
});
|
|
renderCart();
|
|
partSearchEl.focus();
|
|
}
|
|
|
|
function renderCart() {
|
|
var tbody = document.getElementById('cart-body');
|
|
|
|
if (cart.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>';
|
|
updateTotals();
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = cart.map(function (item, i) {
|
|
var lineTotal = item.quantity * item.unit_price;
|
|
return '<tr>' +
|
|
'<td class="cart-desc">' + esc(item.description) + '</td>' +
|
|
'<td><span class="pri-type ' + item.part_type + '">' + item.part_type + '</span></td>' +
|
|
'<td><input class="cart-qty" type="number" min="1" value="' + item.quantity + '" data-idx="' + i + '" data-field="quantity"></td>' +
|
|
'<td><input class="cart-cost" type="number" step="0.01" value="' + item.unit_cost.toFixed(2) + '" data-idx="' + i + '" data-field="unit_cost"></td>' +
|
|
'<td><input class="cart-margin" type="number" step="1" value="' + item.margin_pct.toFixed(0) + '" data-idx="' + i + '" data-field="margin_pct">%</td>' +
|
|
'<td>' + fmt(item.unit_price) + '</td>' +
|
|
'<td>' + fmt(lineTotal) + '</td>' +
|
|
'<td><button class="cart-remove" data-idx="' + i + '">×</button></td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
|
|
// Input change handlers
|
|
tbody.querySelectorAll('input').forEach(function (input) {
|
|
input.addEventListener('change', function () {
|
|
var idx = parseInt(input.getAttribute('data-idx'));
|
|
var field = input.getAttribute('data-field');
|
|
var val = parseFloat(input.value) || 0;
|
|
cart[idx][field] = val;
|
|
|
|
// Recalculate price from cost + margin
|
|
if (field === 'unit_cost' || field === 'margin_pct') {
|
|
cart[idx].unit_price = cart[idx].unit_cost * (1 + cart[idx].margin_pct / 100);
|
|
}
|
|
|
|
renderCart();
|
|
});
|
|
});
|
|
|
|
// Remove handlers
|
|
tbody.querySelectorAll('.cart-remove').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
cart.splice(parseInt(btn.getAttribute('data-idx')), 1);
|
|
renderCart();
|
|
});
|
|
});
|
|
|
|
updateTotals();
|
|
}
|
|
|
|
function updateTotals() {
|
|
var itemCount = cart.reduce(function (sum, it) { return sum + it.quantity; }, 0);
|
|
var subtotal = cart.reduce(function (sum, it) { return sum + (it.quantity * it.unit_price); }, 0);
|
|
var tax = subtotal * 0.16;
|
|
var total = subtotal + tax;
|
|
|
|
document.getElementById('sum-items').textContent = itemCount;
|
|
document.getElementById('sum-subtotal').textContent = fmt(subtotal);
|
|
document.getElementById('sum-tax').textContent = fmt(tax);
|
|
document.getElementById('sum-total').textContent = fmt(total);
|
|
|
|
updateFacturarBtn();
|
|
}
|
|
|
|
function updateFacturarBtn() {
|
|
document.getElementById('btn-facturar').disabled = !(selectedCustomer && cart.length > 0);
|
|
}
|
|
|
|
// ================================================================
|
|
// Facturar
|
|
// ================================================================
|
|
|
|
document.getElementById('btn-facturar').addEventListener('click', function () {
|
|
if (!selectedCustomer || cart.length === 0) return;
|
|
|
|
var btn = this;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Generando...';
|
|
|
|
var items = cart.map(function (it) {
|
|
return {
|
|
part_id: it.part_id,
|
|
aftermarket_id: it.aftermarket_id,
|
|
description: it.description,
|
|
quantity: it.quantity,
|
|
unit_cost: it.unit_cost,
|
|
margin_pct: it.margin_pct,
|
|
unit_price: Math.round(it.unit_price * 100) / 100
|
|
};
|
|
});
|
|
|
|
api('/api/pos/invoices', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
customer_id: selectedCustomer.id_customer,
|
|
items: items,
|
|
notes: document.getElementById('invoice-notes').value.trim()
|
|
})
|
|
}).then(function (res) {
|
|
toast('Factura ' + res.folio + ' creada por ' + fmt(res.total));
|
|
|
|
// Reset cart
|
|
cart = [];
|
|
renderCart();
|
|
document.getElementById('invoice-notes').value = '';
|
|
|
|
// Refresh customer balance
|
|
selectCustomer(selectedCustomer.id_customer);
|
|
|
|
btn.textContent = 'Facturar';
|
|
}).catch(function (err) {
|
|
toast(err.message, 'error');
|
|
btn.disabled = false;
|
|
btn.textContent = 'Facturar';
|
|
});
|
|
});
|
|
|
|
// ================================================================
|
|
// Init
|
|
// ================================================================
|
|
|
|
renderCart();
|
|
})();
|