feat(pos): add POS sale page — F-keys, payment modal, margin display, ticket print
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
928
pos/static/js/pos.js
Normal file
928
pos/static/js/pos.js
Normal file
@@ -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 =
|
||||
`<span>Caja #${data.register.register_number}</span>`;
|
||||
document.getElementById('registerInfo').classList.remove('no-register');
|
||||
} else {
|
||||
document.getElementById('registerInfo').innerHTML =
|
||||
'<span>Sin caja abierta</span>';
|
||||
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 ? `<td class="num">${fmt(item.unit_cost)}</td>` : '';
|
||||
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 = `<td class="num"><span class="${cls}">${marginPct}%</span></td>`;
|
||||
}
|
||||
|
||||
html += `<tr class="${i === selectedRow ? 'selected' : ''}" onclick="POS.selectRow(${i})">
|
||||
<td>${i + 1}</td>
|
||||
<td>
|
||||
<div class="part-name">${item.name}</div>
|
||||
<div class="part-number">${item.part_number} | Stock: ${item.stock}</div>
|
||||
</td>
|
||||
<td><input type="number" class="qty-input" value="${item.quantity}" min="1"
|
||||
onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td>
|
||||
<td class="num">${fmt(item.unit_price)}</td>
|
||||
<td><input type="number" class="discount-input" value="${item.discount_pct}" min="0" max="100" step="0.5"
|
||||
onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td>
|
||||
<td class="num">${fmt(lineSubtotal)}</td>
|
||||
${costHtml}
|
||||
${marginHtml}
|
||||
<td><button class="btn-remove" onclick="event.stopPropagation(); POS.removeFromCart(${i})">×</button></td>
|
||||
</tr>`;
|
||||
});
|
||||
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 = '<div style="padding:20px;text-align:center;color:#999;">Sin resultados</div>';
|
||||
} 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 += `<div class="search-result-item" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div class="sr-name">${item.name}</div>
|
||||
<div class="sr-pn">${item.part_number} | ${item.brand || ''}</div>
|
||||
<div class="sr-stock ${stockClass}">Stock: ${item.stock}</div>
|
||||
</div>
|
||||
<div class="sr-price">${fmt(price)}</div>
|
||||
</div>`;
|
||||
});
|
||||
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 = '<div class="ac-item" style="color:#999;">Sin resultados</div>';
|
||||
} else {
|
||||
let html = '';
|
||||
data.data.forEach(c => {
|
||||
const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' };
|
||||
html += `<div class="ac-item" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "'")})'>
|
||||
<div>${c.name}</div>
|
||||
<div class="ac-meta">${c.rfc || ''} | ${c.phone || ''} | ${tiers[c.price_tier] || 'P1'} | Credito: ${fmt(c.credit_balance)}/${fmt(c.credit_limit)}</div>
|
||||
</div>`;
|
||||
});
|
||||
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,
|
||||
};
|
||||
})();
|
||||
403
pos/templates/pos.html
Normal file
403
pos/templates/pos.html
Normal file
@@ -0,0 +1,403 @@
|
||||
<!-- /home/Autopartes/pos/templates/pos.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Punto de Venta - Nexus POS</title>
|
||||
<link rel="stylesheet" href="/pos/static/css/common.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* Top bar */
|
||||
.pos-topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 8px 16px; display: flex; align-items: center; justify-content: space-between; font-size: 14px; }
|
||||
.pos-topbar .employee-info { display: flex; align-items: center; gap: 12px; }
|
||||
.pos-topbar .register-info { display: flex; align-items: center; gap: 8px; opacity: 0.8; }
|
||||
.pos-topbar .register-info.no-register { color: #ff6b6b; opacity: 1; }
|
||||
.pos-topbar .shortcuts-hint { font-size: 12px; opacity: 0.6; }
|
||||
|
||||
/* Main layout */
|
||||
.pos-main { display: flex; flex: 1; overflow: hidden; }
|
||||
|
||||
/* Left panel: search + items */
|
||||
.pos-left { width: 55%; display: flex; flex-direction: column; border-right: 2px solid var(--color-border, #ddd); background: #fff; }
|
||||
|
||||
/* Customer bar */
|
||||
.customer-bar { padding: 8px 12px; background: var(--color-surface, #f8f9fa); border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; align-items: center; }
|
||||
.customer-bar input { flex: 1; padding: 8px 12px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
|
||||
.customer-bar .customer-selected { background: #e8f5e9; padding: 6px 12px; border-radius: var(--radius, 6px); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.customer-bar .customer-selected .tier-badge { font-size: 11px; padding: 2px 6px; border-radius: 3px; background: #1976d2; color: #fff; }
|
||||
.customer-bar .customer-selected .credit-info { font-size: 11px; color: #666; }
|
||||
.customer-bar .btn-new-customer { padding: 8px 12px; border: 1px dashed var(--color-border, #ddd); border-radius: var(--radius, 6px); background: none; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||
.customer-autocomplete { position: absolute; top: 100%; left: 0; right: 0; background: #fff; border: 1px solid var(--color-border, #ddd); border-radius: 0 0 var(--radius, 6px) var(--radius, 6px); box-shadow: var(--shadow, 0 4px 12px rgba(0,0,0,0.1)); z-index: 100; max-height: 200px; overflow-y: auto; }
|
||||
.customer-autocomplete .ac-item { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
|
||||
.customer-autocomplete .ac-item:hover { background: #f0f2f5; }
|
||||
.customer-autocomplete .ac-item .ac-meta { font-size: 11px; color: #999; }
|
||||
|
||||
/* Vehicle info banner */
|
||||
.vehicle-banner { display: none; padding: 6px 12px; background: #fff3e0; border-bottom: 1px solid #ffe0b2; font-size: 12px; }
|
||||
.vehicle-banner.visible { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Search bar */
|
||||
.search-bar { padding: 8px 12px; border-bottom: 1px solid var(--color-border, #ddd); display: flex; gap: 8px; }
|
||||
.search-bar input { flex: 1; padding: 10px 14px; border: 2px solid var(--color-primary, #1a1a2e); border-radius: var(--radius, 6px); font-size: 15px; outline: none; }
|
||||
.search-bar input:focus { border-color: var(--color-accent, #4361ee); box-shadow: 0 0 0 3px rgba(67,97,238,0.15); }
|
||||
|
||||
/* Cart items */
|
||||
.cart-items { flex: 1; overflow-y: auto; padding: 0; }
|
||||
.cart-items table { width: 100%; border-collapse: collapse; }
|
||||
.cart-items thead th { position: sticky; top: 0; background: var(--color-surface, #f8f9fa); padding: 8px 10px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
|
||||
.cart-items tbody tr { border-bottom: 1px solid #f0f0f0; cursor: pointer; }
|
||||
.cart-items tbody tr:hover { background: #f8f9fa; }
|
||||
.cart-items tbody tr.selected { background: #e3f2fd; }
|
||||
.cart-items td { padding: 8px 10px; font-size: 13px; }
|
||||
.cart-items td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.cart-items td .part-name { font-weight: 500; }
|
||||
.cart-items td .part-number { font-size: 11px; color: #999; }
|
||||
.cart-items td .margin-info { font-size: 11px; color: #888; }
|
||||
.cart-items td .margin-warning { color: #e53935; font-weight: 600; }
|
||||
.cart-items .qty-input { width: 50px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
|
||||
.cart-items .discount-input { width: 55px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
|
||||
.cart-items .btn-remove { background: none; border: none; color: #e53935; cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
|
||||
.cart-empty { display: flex; align-items: center; justify-content: center; flex: 1; color: #999; font-size: 15px; }
|
||||
|
||||
/* Right panel: totals + actions */
|
||||
.pos-right { width: 45%; display: flex; flex-direction: column; background: var(--color-surface, #f8f9fa); }
|
||||
|
||||
/* Search results (right side when searching) */
|
||||
.search-results { flex: 1; overflow-y: auto; padding: 8px; display: none; }
|
||||
.search-results.active { display: block; }
|
||||
.search-result-item { padding: 10px 12px; background: #fff; border: 1px solid #eee; border-radius: var(--radius, 6px); margin-bottom: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-result-item:hover { border-color: var(--color-primary, #1a1a2e); background: #fafafa; }
|
||||
.search-result-item .sr-name { font-weight: 500; font-size: 14px; }
|
||||
.search-result-item .sr-pn { font-size: 12px; color: #666; }
|
||||
.search-result-item .sr-stock { font-size: 12px; }
|
||||
.search-result-item .sr-stock.zero { color: #e53935; }
|
||||
.search-result-item .sr-price { font-weight: 600; font-size: 15px; }
|
||||
|
||||
/* Totals panel */
|
||||
.totals-panel { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; padding: 16px; }
|
||||
.totals-panel.hidden { display: none; }
|
||||
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
|
||||
.totals-row.discount { color: #e53935; }
|
||||
.totals-row.total { font-size: 28px; font-weight: 700; padding: 12px 0; border-top: 2px solid var(--color-border, #ddd); margin-top: 8px; }
|
||||
|
||||
/* Global discount */
|
||||
.global-discount { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px solid #eee; margin-top: 8px; }
|
||||
.global-discount label { font-size: 13px; color: #666; }
|
||||
.global-discount input { width: 60px; text-align: center; border: 1px solid #ddd; border-radius: 4px; padding: 4px; font-size: 13px; }
|
||||
|
||||
/* Action buttons */
|
||||
.action-buttons { padding: 12px 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; border-top: 2px solid var(--color-border, #ddd); }
|
||||
.action-buttons .btn { padding: 14px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 6px; transition: opacity 0.15s; }
|
||||
.action-buttons .btn:hover { opacity: 0.85; }
|
||||
.action-buttons .btn:active { transform: scale(0.98); }
|
||||
.btn-cobrar { background: #2e7d32; color: #fff; grid-column: 1 / -1; font-size: 18px; padding: 18px; }
|
||||
.btn-cotizacion { background: #1565c0; color: #fff; }
|
||||
.btn-apartado { background: #e65100; color: #fff; }
|
||||
.btn-credito { background: #6a1b9a; color: #fff; }
|
||||
.btn-last-sale { background: #455a64; color: #fff; }
|
||||
.btn-shortcut { font-size: 11px; opacity: 0.7; margin-left: 4px; }
|
||||
|
||||
/* Payment modal */
|
||||
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal { background: #fff; border-radius: 12px; padding: 24px; width: 500px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
|
||||
.modal h2 { margin-bottom: 16px; font-size: 20px; }
|
||||
.modal .modal-total { font-size: 32px; font-weight: 700; text-align: center; padding: 12px; background: #f5f5f5; border-radius: 8px; margin-bottom: 16px; }
|
||||
.modal .payment-methods { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.modal .payment-methods .pm-btn { flex: 1; padding: 12px; border: 2px solid #ddd; border-radius: 8px; background: #fff; cursor: pointer; text-align: center; font-size: 13px; font-weight: 500; }
|
||||
.modal .payment-methods .pm-btn.active { border-color: var(--color-primary, #1a1a2e); background: #f0f4ff; }
|
||||
.modal .payment-field { margin-bottom: 12px; }
|
||||
.modal .payment-field label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; }
|
||||
.modal .payment-field input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
|
||||
.modal .payment-field input.amount-input { font-size: 24px; text-align: right; font-weight: 600; }
|
||||
.modal .change-display { text-align: center; padding: 12px; font-size: 20px; font-weight: 600; border-radius: 8px; margin: 12px 0; }
|
||||
.modal .change-display.positive { background: #e8f5e9; color: #2e7d32; }
|
||||
.modal .change-display.negative { background: #ffebee; color: #c62828; }
|
||||
|
||||
/* Mixed payment rows */
|
||||
.mixed-payments { margin-bottom: 12px; }
|
||||
.mixed-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.mixed-row select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
|
||||
.mixed-row input { flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
|
||||
.mixed-row .btn-remove-row { background: none; border: none; color: #e53935; cursor: pointer; font-size: 18px; }
|
||||
.btn-add-mixed { background: none; border: 1px dashed #999; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #666; }
|
||||
|
||||
.modal .modal-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
.modal .modal-actions .btn { flex: 1; padding: 14px; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||
.modal .btn-confirm-payment { background: #2e7d32; color: #fff; }
|
||||
.modal .btn-cancel-modal { background: #eee; color: #333; }
|
||||
|
||||
/* New customer modal */
|
||||
.modal .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.modal .form-grid .full-width { grid-column: 1 / -1; }
|
||||
.modal .form-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.modal .form-field label { font-size: 12px; color: #666; font-weight: 500; }
|
||||
.modal .form-field input, .modal .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
|
||||
|
||||
/* Ticket preview */
|
||||
.ticket-preview { font-family: var(--font-mono, 'Courier New', monospace); font-size: 12px; white-space: pre-wrap; background: #fff; padding: 16px; border: 1px dashed #999; max-width: 300px; margin: 0 auto; line-height: 1.4; }
|
||||
|
||||
/* Keyboard hint bar */
|
||||
.keyboard-bar { background: #263238; color: #b0bec5; padding: 6px 16px; display: flex; gap: 16px; font-size: 11px; }
|
||||
.keyboard-bar .kb-key { background: #37474f; padding: 2px 6px; border-radius: 3px; color: #e0e0e0; font-weight: 600; margin-right: 4px; }
|
||||
|
||||
@media print {
|
||||
body * { visibility: hidden; }
|
||||
.ticket-preview, .ticket-preview * { visibility: visible; }
|
||||
.ticket-preview { position: absolute; left: 0; top: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top Bar -->
|
||||
<div class="pos-topbar">
|
||||
<div class="employee-info">
|
||||
<span id="employeeName">Cargando...</span>
|
||||
<span id="branchName" style="opacity: 0.7; font-size: 12px;"></span>
|
||||
</div>
|
||||
<div class="register-info" id="registerInfo">
|
||||
<span>Caja: --</span>
|
||||
</div>
|
||||
<div class="shortcuts-hint">F1=Buscar F2=Cliente F3=Cobrar F4=Cotizacion F5=Ult.Venta</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout -->
|
||||
<div class="pos-main">
|
||||
<!-- Left: Search + Cart -->
|
||||
<div class="pos-left">
|
||||
<!-- Customer Bar -->
|
||||
<div class="customer-bar" style="position: relative;">
|
||||
<span style="font-size: 13px; color: #666;">Cliente:</span>
|
||||
<div id="customerSearchWrap" style="flex: 1; position: relative;">
|
||||
<input type="text" id="customerSearch" placeholder="Buscar cliente por nombre, RFC, telefono... (F2)" autocomplete="off">
|
||||
<div class="customer-autocomplete" id="customerAutocomplete" style="display:none;"></div>
|
||||
</div>
|
||||
<div id="customerSelected" class="customer-selected" style="display:none;">
|
||||
<span id="customerName"></span>
|
||||
<span class="tier-badge" id="customerTier"></span>
|
||||
<span class="credit-info" id="customerCredit"></span>
|
||||
<button onclick="POS.clearCustomer()" style="background:none;border:none;cursor:pointer;color:#999;font-size:16px;" title="Quitar cliente">×</button>
|
||||
</div>
|
||||
<button class="btn-new-customer" onclick="POS.showNewCustomerModal()">+ Nuevo</button>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Banner -->
|
||||
<div class="vehicle-banner" id="vehicleBanner">
|
||||
<span style="font-weight: 600;">Vehiculo:</span>
|
||||
<span id="vehicleInfo"></span>
|
||||
<span id="lastPurchaseInfo" style="margin-left: auto; font-size: 11px; color: #e65100;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="search-bar">
|
||||
<input type="text" id="itemSearch" placeholder="Buscar por # parte, nombre o codigo de barras... (F1)" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Cart Table -->
|
||||
<div class="cart-items" id="cartContainer">
|
||||
<div class="cart-empty" id="cartEmpty">
|
||||
<div>Carrito vacio<br><span style="font-size: 13px;">Busca productos o presiona F1</span></div>
|
||||
</div>
|
||||
<table id="cartTable" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30px;">#</th>
|
||||
<th>Producto</th>
|
||||
<th style="width: 60px;">Cant</th>
|
||||
<th style="width: 90px;">Precio</th>
|
||||
<th style="width: 65px;">Desc%</th>
|
||||
<th style="width: 90px;" class="num">Subtotal</th>
|
||||
<th id="thCost" style="width: 70px; display:none;" class="num">Costo</th>
|
||||
<th id="thMargin" style="width: 65px; display:none;" class="num">Margen</th>
|
||||
<th style="width: 30px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cartBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Search Results / Totals + Actions -->
|
||||
<div class="pos-right">
|
||||
<!-- Search results (shown when searching) -->
|
||||
<div class="search-results" id="searchResults"></div>
|
||||
|
||||
<!-- Totals panel -->
|
||||
<div class="totals-panel" id="totalsPanel">
|
||||
<div>
|
||||
<div class="totals-row"><span>Subtotal:</span><span id="dispSubtotal">$0.00</span></div>
|
||||
<div class="totals-row discount" id="discountRow" style="display:none;">
|
||||
<span>Descuento:</span><span id="dispDiscount">-$0.00</span>
|
||||
</div>
|
||||
<div class="totals-row"><span>IVA (16%):</span><span id="dispTax">$0.00</span></div>
|
||||
<div class="totals-row total"><span>TOTAL:</span><span id="dispTotal">$0.00</span></div>
|
||||
</div>
|
||||
<div class="global-discount">
|
||||
<label>Descuento global:</label>
|
||||
<input type="number" id="globalDiscount" value="0" min="0" max="100" step="0.5">
|
||||
<span>%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-cobrar" onclick="POS.checkout()">
|
||||
Cobrar<span class="btn-shortcut">F3</span>
|
||||
</button>
|
||||
<button class="btn btn-cotizacion" onclick="POS.saveQuotation()">
|
||||
Cotizacion<span class="btn-shortcut">F4</span>
|
||||
</button>
|
||||
<button class="btn btn-apartado" onclick="POS.createLayaway()">
|
||||
Apartado
|
||||
</button>
|
||||
<button class="btn btn-credito" onclick="POS.creditSale()">
|
||||
Credito
|
||||
</button>
|
||||
<button class="btn btn-last-sale" onclick="POS.showLastSale()">
|
||||
Ult. Venta<span class="btn-shortcut">F5</span>
|
||||
</button>
|
||||
<button class="btn" style="background:#78909c;color:#fff;" onclick="POS.openDrawer()">
|
||||
Cajon<span class="btn-shortcut">F6</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard hints -->
|
||||
<div class="keyboard-bar">
|
||||
<span><span class="kb-key">F1</span>Buscar</span>
|
||||
<span><span class="kb-key">F2</span>Cliente</span>
|
||||
<span><span class="kb-key">F3</span>Cobrar</span>
|
||||
<span><span class="kb-key">F4</span>Cotizacion</span>
|
||||
<span><span class="kb-key">F5</span>Ult.Venta</span>
|
||||
<span><span class="kb-key">F6</span>Cajon</span>
|
||||
<span><span class="kb-key">+/-</span>Cantidad</span>
|
||||
<span><span class="kb-key">*</span>Descuento</span>
|
||||
<span><span class="kb-key">Enter</span>Agregar</span>
|
||||
<span><span class="kb-key">Del</span>Eliminar</span>
|
||||
</div>
|
||||
|
||||
<!-- Payment Modal -->
|
||||
<div class="modal-overlay" id="paymentModal">
|
||||
<div class="modal">
|
||||
<h2>Cobrar Venta</h2>
|
||||
<div class="modal-total" id="modalTotal">$0.00</div>
|
||||
|
||||
<div class="payment-methods">
|
||||
<button class="pm-btn active" data-method="efectivo" onclick="POS.selectPaymentMethod('efectivo', this)">Efectivo</button>
|
||||
<button class="pm-btn" data-method="transferencia" onclick="POS.selectPaymentMethod('transferencia', this)">Transferencia</button>
|
||||
<button class="pm-btn" data-method="tarjeta" onclick="POS.selectPaymentMethod('tarjeta', this)">Tarjeta</button>
|
||||
<button class="pm-btn" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">Mixto</button>
|
||||
</div>
|
||||
|
||||
<!-- Cash payment -->
|
||||
<div id="cashPayment">
|
||||
<div class="payment-field">
|
||||
<label>Monto recibido:</label>
|
||||
<input type="number" id="cashReceived" class="amount-input" step="0.01" min="0" oninput="POS.updateChange()">
|
||||
</div>
|
||||
<div class="change-display positive" id="changeDisplay">Cambio: $0.00</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer/Card payment -->
|
||||
<div id="refPayment" style="display:none;">
|
||||
<div class="payment-field">
|
||||
<label>Referencia:</label>
|
||||
<input type="text" id="paymentRef" placeholder="Numero de referencia o autorizacion">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mixed payment -->
|
||||
<div id="mixedPayment" style="display:none;">
|
||||
<div class="mixed-payments" id="mixedRows">
|
||||
<div class="mixed-row">
|
||||
<select><option value="efectivo">Efectivo</option><option value="transferencia">Transferencia</option><option value="tarjeta">Tarjeta</option></select>
|
||||
<input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
|
||||
<input type="text" placeholder="Ref." style="width: 100px;">
|
||||
</div>
|
||||
<div class="mixed-row">
|
||||
<select><option value="transferencia">Transferencia</option><option value="efectivo">Efectivo</option><option value="tarjeta">Tarjeta</option></select>
|
||||
<input type="number" step="0.01" min="0" placeholder="Monto" class="mixed-amount" oninput="POS.updateMixedTotal()">
|
||||
<input type="text" placeholder="Ref." style="width: 100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="mixedRemaining" style="text-align:center; font-size: 14px; color: #666; padding: 8px 0;">Faltante: $0.00</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-cancel-modal" onclick="POS.closePaymentModal()">Cancelar (Esc)</button>
|
||||
<button class="btn btn-confirm-payment" id="btnConfirmPayment" onclick="POS.confirmPayment()">Confirmar Pago</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Customer Modal -->
|
||||
<div class="modal-overlay" id="newCustomerModal">
|
||||
<div class="modal">
|
||||
<h2>Nuevo Cliente</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-field full-width"><label>Nombre *</label><input type="text" id="ncName"></div>
|
||||
<div class="form-field"><label>RFC</label><input type="text" id="ncRfc" maxlength="13" placeholder="XAXX010101000"></div>
|
||||
<div class="form-field"><label>Razon Social</label><input type="text" id="ncRazonSocial"></div>
|
||||
<div class="form-field"><label>Regimen Fiscal</label>
|
||||
<select id="ncRegimenFiscal">
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="601">601 - General de Ley PM</option>
|
||||
<option value="603">603 - Personas Morales Fines No Lucrativos</option>
|
||||
<option value="605">605 - Sueldos y Salarios</option>
|
||||
<option value="606">606 - Arrendamiento</option>
|
||||
<option value="612">612 - Personas Fisicas Actividad Empresarial</option>
|
||||
<option value="616">616 - Sin Obligaciones Fiscales</option>
|
||||
<option value="621">621 - Incorporacion Fiscal</option>
|
||||
<option value="625">625 - RESICO</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field"><label>Uso CFDI</label>
|
||||
<select id="ncUsoCfdi">
|
||||
<option value="G03">G03 - Gastos en general</option>
|
||||
<option value="G01">G01 - Adquisicion de mercancias</option>
|
||||
<option value="P01">P01 - Por definir</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field"><label>Telefono</label><input type="tel" id="ncPhone"></div>
|
||||
<div class="form-field"><label>Email</label><input type="email" id="ncEmail"></div>
|
||||
<div class="form-field"><label>Lista de precio</label>
|
||||
<select id="ncPriceTier">
|
||||
<option value="1">Mostrador (Precio 1)</option>
|
||||
<option value="2">Taller (Precio 2)</option>
|
||||
<option value="3">Mayoreo (Precio 3)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field"><label>Limite de credito</label><input type="number" id="ncCreditLimit" value="0" min="0" step="100"></div>
|
||||
<div class="form-field full-width"><label>Vehiculo (opcional)</label></div>
|
||||
<div class="form-field"><label>Marca</label><input type="text" id="ncVehMake" placeholder="Nissan, Toyota..."></div>
|
||||
<div class="form-field"><label>Modelo</label><input type="text" id="ncVehModel" placeholder="Tsuru, Corolla..."></div>
|
||||
<div class="form-field"><label>Ano</label><input type="number" id="ncVehYear" placeholder="2020"></div>
|
||||
<div class="form-field"><label>Placas</label><input type="text" id="ncVehPlates"></div>
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top: 16px;">
|
||||
<button class="btn btn-cancel-modal" onclick="POS.closeNewCustomerModal()">Cancelar</button>
|
||||
<button class="btn btn-confirm-payment" onclick="POS.saveNewCustomer()">Guardar Cliente</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Modal -->
|
||||
<div class="modal-overlay" id="ticketModal">
|
||||
<div class="modal" style="width: 380px;">
|
||||
<h2>Venta Completada</h2>
|
||||
<div class="ticket-preview" id="ticketPreview"></div>
|
||||
<div class="modal-actions" style="margin-top: 16px;">
|
||||
<button class="btn btn-cancel-modal" onclick="POS.closeTicketModal()">Cerrar</button>
|
||||
<button class="btn btn-confirm-payment" onclick="POS.printTicket()">Imprimir</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/pos.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user