1924 lines
86 KiB
JavaScript
1924 lines
86 KiB
JavaScript
// /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 = sessionStorage.getItem('pos_last_sale_id') || null;
|
|
let lastSaleData = null;
|
|
let searchTimeout = null;
|
|
let customerSearchTimeout = null;
|
|
let canCancel = false;
|
|
let canDiscount = false;
|
|
let canEditPrice = false;
|
|
let canCreateWorkshopOrder = false;
|
|
let canCreateLayaway = false;
|
|
let canCreateRemission = false;
|
|
let counterRemissionEnabled = false;
|
|
let allowZeroPriceSales = true;
|
|
let allowNegativeStock = false;
|
|
let workshopModuleEnabled = true;
|
|
let currentPerms = [];
|
|
let receiptConfig = {};
|
|
let couriers = [];
|
|
let selectedCourierId = 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 });
|
|
|
|
// Price based on customer tier: base price (price_1) with tier discount.
|
|
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
|
function priceForTier(basePrice, tier) {
|
|
const p = parseFloat(basePrice) || 0;
|
|
const t = parseInt(tier, 10) || 1;
|
|
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
|
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
|
return p;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// ─── Permission-based UI toggles ─────
|
|
function applyPermissionsUI() {
|
|
function hide(selector) {
|
|
const el = document.querySelector(selector);
|
|
if (el) el.style.display = 'none';
|
|
}
|
|
function show(selector) {
|
|
const el = document.querySelector(selector);
|
|
if (el) el.style.display = '';
|
|
}
|
|
if (!canCancel) {
|
|
hide('#btnCancelSale');
|
|
hide('#fkeyEsc');
|
|
}
|
|
if (!canDiscount) hide('[onclick="POS.applyDiscount()"]');
|
|
if (!canEditPrice) hide('[onclick="POS.modifyPrice()"]');
|
|
if (!canCreateWorkshopOrder || !workshopModuleEnabled) {
|
|
hide('[onclick="POS.createServiceOrder()"]');
|
|
hide('[title="Orden de servicio"]');
|
|
}
|
|
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
|
|
|
|
// Counter remission workflow
|
|
if (canCreateRemission) {
|
|
hide('#btnCobrar');
|
|
hide('.fkey[onclick="POS.checkout()"]');
|
|
|
|
hide('[onclick="POS.createLayaway()"]');
|
|
hide('[onclick="POS.saveQuotation()"]');
|
|
show('#btnRemission');
|
|
show('#courierSelectField');
|
|
} else {
|
|
hide('#btnRemission');
|
|
hide('#courierSelectField');
|
|
}
|
|
if (!currentPerms.includes('pos.sell')) {
|
|
hide('#btnPayRemission');
|
|
} else {
|
|
show('#btnPayRemission');
|
|
}
|
|
}
|
|
|
|
// ─── 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 || '';
|
|
const perms = payload.permissions || [];
|
|
currentPerms = perms;
|
|
const employeeRole = payload.role || '';
|
|
canViewCost = perms.includes('pos.view_cost');
|
|
canCancel = perms.includes('pos.cancel');
|
|
canDiscount = perms.includes('pos.discount');
|
|
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
|
|
canCreateWorkshopOrder = perms.includes('workshop.edit');
|
|
canCreateLayaway = perms.includes('pos.sell');
|
|
employeeMaxDiscount = payload.max_discount_pct || 100;
|
|
|
|
// Counter remission feature
|
|
try {
|
|
const crCfg = await api('/pos/api/config/counter-remission');
|
|
counterRemissionEnabled = crCfg.enabled === true;
|
|
} catch (e) {
|
|
counterRemissionEnabled = false;
|
|
}
|
|
// Sales settings (zero-price and negative-stock toggles)
|
|
try {
|
|
const ssCfg = await api('/pos/api/config/sales-settings');
|
|
allowZeroPriceSales = ssCfg.allow_zero_price_sales !== false;
|
|
allowNegativeStock = ssCfg.allow_negative_stock === true;
|
|
} catch (e) {
|
|
allowZeroPriceSales = true;
|
|
allowNegativeStock = false;
|
|
}
|
|
// Module toggles (from app-init preloaded modules)
|
|
try {
|
|
var modules = window.POS_USER && window.POS_USER.modules;
|
|
if (!modules) modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
|
|
workshopModuleEnabled = modules.workshop !== false;
|
|
} catch (e) {
|
|
workshopModuleEnabled = true;
|
|
}
|
|
// Counter remission workflow applies only to the counter role.
|
|
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
|
|
|
|
// 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();
|
|
}
|
|
|
|
applyPermissionsUI();
|
|
} 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 and receipt config
|
|
await loadRegister();
|
|
await loadReceiptConfig();
|
|
await loadCouriers();
|
|
|
|
// 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>`;
|
|
} else {
|
|
currentRegister = null;
|
|
document.getElementById('registerInfo').innerHTML =
|
|
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">⚠ Sin caja abierta — Clic para abrir</span>';
|
|
// Force open register modal on first load only for users who can sell
|
|
if (currentPerms.includes('pos.sell')) {
|
|
showOpenRegisterModal();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Register check failed:', e);
|
|
}
|
|
}
|
|
|
|
async function loadReceiptConfig() {
|
|
try {
|
|
receiptConfig = await api('/pos/api/config/receipt');
|
|
} catch (e) {
|
|
console.warn('Could not load receipt config:', e);
|
|
receiptConfig = {};
|
|
}
|
|
}
|
|
|
|
async function loadCouriers() {
|
|
try {
|
|
const data = await api('/pos/api/logistics/couriers');
|
|
couriers = data.couriers || [];
|
|
const sel = document.getElementById('remissionCourier');
|
|
if (sel) {
|
|
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
|
|
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
|
sel.addEventListener('change', () => {
|
|
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load couriers:', e);
|
|
couriers = [];
|
|
}
|
|
}
|
|
|
|
function rc(key, fallback) {
|
|
const v = receiptConfig[key];
|
|
return v !== undefined && v !== null && v !== '' ? v : fallback;
|
|
}
|
|
|
|
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 = '<span style="color:var(--color-error);">Numero de caja invalido</span>';
|
|
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 = '<span style="color:var(--color-error);">' + (data.error || 'Error') + '</span>';
|
|
return;
|
|
}
|
|
currentRegister = data;
|
|
document.getElementById('registerInfo').innerHTML = `<span>Caja #${data.register_number}</span>`;
|
|
closeOpenRegisterModal();
|
|
showToast(`Caja #${data.register_number} abierta con $${amount.toFixed(2)}`);
|
|
} catch (e) {
|
|
document.getElementById('registerOpenResult').innerHTML = '<span style="color:var(--color-error);">Error de red</span>';
|
|
}
|
|
}
|
|
|
|
// ─── 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 = '<p style="color:var(--color-error);">' + data.error + '</p>';
|
|
return;
|
|
}
|
|
let html = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);font-size:var(--text-body-sm);">';
|
|
html += '<div><span style="color:var(--color-text-muted);">Efectivo inicial</span><br/><strong>' + fmt(data.opening_amount) + '</strong></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Ventas totales</span><br/><strong>' + fmt(data.total_sales) + '</strong> (' + data.total_sales_count + ')</div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Efectivo en ventas</span><br/><strong>' + fmt(data.cash_from_sales) + '</strong></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Cambio entregado</span><br/><strong>-' + fmt(data.change_given) + '</strong></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Entradas de efectivo</span><br/><strong>+' + fmt(data.cash_movements_in) + '</strong></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Salidas de efectivo</span><br/><strong>-' + fmt(data.cash_movements_out) + '</strong></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Cancelaciones</span><br/><strong>' + data.cancelled_count + ' (' + fmt(data.cancelled_amount) + ')</strong></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Efectivo esperado</span><br/><strong style="color:var(--color-accent);font-size:1.1em;">' + fmt(data.expected_cash) + '</strong></div>';
|
|
html += '</div>';
|
|
if (data.sales_by_method && Object.keys(data.sales_by_method).length) {
|
|
html += '<div style="margin-top:var(--space-3);padding-top:var(--space-3);border-top:1px solid var(--color-border);">';
|
|
html += '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Por metodo de pago:</span><br/>';
|
|
for (const [method, info] of Object.entries(data.sales_by_method)) {
|
|
html += '<span style="font-size:var(--text-body-sm);margin-right:var(--space-3);">' + method + ': ' + fmt(info.amount) + ' (' + info.count + ')</span>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
if (data.movement_detail && data.movement_detail.length) {
|
|
html += '<div style="margin-top:var(--space-3);padding-top:var(--space-3);border-top:1px solid var(--color-border);">';
|
|
html += '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Movimientos de caja:</span><br/>';
|
|
data.movement_detail.forEach(function(m) {
|
|
html += '<div style="font-size:var(--text-body-sm);">' + m.type + ' ' + fmt(m.amount) + ' — ' + (m.reason || '') + '</div>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
el.innerHTML = html;
|
|
document.getElementById('cutZClosingAmount').value = data.expected_cash.toFixed(2);
|
|
} catch (e) {
|
|
el.innerHTML = '<p style="color:var(--color-error);">Error cargando resumen</p>';
|
|
}
|
|
}
|
|
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 = '<span style="color:var(--color-error);">' + data.error + '</span>';
|
|
return;
|
|
}
|
|
const diffColor = data.difference > 0 ? 'var(--color-success)' : (data.difference < 0 ? 'var(--color-error)' : 'var(--color-text-muted)');
|
|
document.getElementById('cutZResult').innerHTML = '<span style="color:var(--color-success);">Caja cerrada correctamente</span>';
|
|
document.getElementById('registerInfo').innerHTML = '<span style="color:var(--color-text-muted);" onclick="POS.showOpenRegisterModal()">Sin caja abierta</span>';
|
|
currentRegister = null;
|
|
closeCutZModal();
|
|
showToast('Corte Z completado. Diferencia: $' + data.difference.toFixed(2));
|
|
} catch (e) {
|
|
document.getElementById('cutZResult').innerHTML = '<span style="color:var(--color-error);">Error de red</span>';
|
|
}
|
|
}
|
|
|
|
// ─── Cart ────────────────────────────
|
|
function addToCart(item) {
|
|
const existing = cart.find(c => c.inventory_id === item.inventory_id);
|
|
if (existing) {
|
|
existing.quantity += (item.quantity || 1);
|
|
renderCart();
|
|
if (window.BarcodeFeedback) BarcodeFeedback.success();
|
|
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,
|
|
price_1: parseFloat(item.price_1 || 0),
|
|
price_2: parseFloat(item.price_2 || 0),
|
|
price_3: parseFloat(item.price_3 || 0),
|
|
});
|
|
|
|
renderCart();
|
|
showToast(`${item.name || 'Articulo'} agregado`);
|
|
}
|
|
|
|
function removeFromCart(index) {
|
|
cart.splice(index, 1);
|
|
if (selectedRow >= cart.length) selectedRow = cart.length - 1;
|
|
renderCart();
|
|
}
|
|
|
|
function clearCart() {
|
|
cart.length = 0;
|
|
selectedRow = -1;
|
|
renderCart();
|
|
}
|
|
|
|
function openCancelModal() {
|
|
if (!canCancel) { showToast('No tienes permiso para cancelar'); return; }
|
|
const overlay = document.getElementById('overlay-cancelar-venta');
|
|
const dialog = document.getElementById('modal-cancelar-venta');
|
|
if (overlay) overlay.classList.add('active');
|
|
if (dialog) dialog.classList.add('active');
|
|
}
|
|
|
|
function closeCancelModal() {
|
|
const overlay = document.getElementById('overlay-cancelar-venta');
|
|
const dialog = document.getElementById('modal-cancelar-venta');
|
|
if (overlay) overlay.classList.remove('active');
|
|
if (dialog) dialog.classList.remove('active');
|
|
}
|
|
|
|
function changeQuantity() {
|
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
|
showToast('Selecciona un articulo primero', 'warn');
|
|
return;
|
|
}
|
|
const q = prompt('Nueva cantidad:', cart[selectedRow].quantity);
|
|
if (q !== null) {
|
|
const n = parseInt(q);
|
|
if (n > 0) {
|
|
cart[selectedRow].quantity = n;
|
|
renderCart();
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyDiscount() {
|
|
if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; }
|
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
|
showToast('Selecciona un articulo primero', 'warn');
|
|
return;
|
|
}
|
|
const d = prompt('Descuento %:', cart[selectedRow].discount_pct);
|
|
if (d !== null) {
|
|
const n = parseFloat(d);
|
|
if (n >= 0 && n <= 100) {
|
|
cart[selectedRow].discount_pct = n;
|
|
renderCart();
|
|
}
|
|
}
|
|
}
|
|
|
|
function isZeroPriceItem(item) {
|
|
const unitPrice = parseFloat(item.unit_price || 0);
|
|
const qty = parseFloat(item.quantity || 1);
|
|
const discount = parseFloat(item.discount_pct || 0);
|
|
return (unitPrice * qty * (1 - discount / 100)) <= 0;
|
|
}
|
|
|
|
function findZeroPriceItem() {
|
|
if (allowZeroPriceSales) return null;
|
|
return cart.find(isZeroPriceItem) || null;
|
|
}
|
|
|
|
function modifyPrice() {
|
|
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
|
|
if (selectedRow < 0 || selectedRow >= cart.length) {
|
|
showToast('Selecciona un articulo primero', 'warn');
|
|
return;
|
|
}
|
|
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
|
|
if (p !== null) {
|
|
const n = parseFloat(p);
|
|
if (n < 0) { showToast('Precio no válido'); return; }
|
|
if (!allowZeroPriceSales && n === 0) {
|
|
showToast('No está permitido dejar el precio en $0');
|
|
return;
|
|
}
|
|
cart[selectedRow].unit_price = n;
|
|
renderCart();
|
|
}
|
|
}
|
|
|
|
// Wire confirm-cancel button
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
var btn = document.getElementById('btnConfirmCancel');
|
|
if (btn) {
|
|
btn.addEventListener('click', function() {
|
|
clearCart();
|
|
closeCancelModal();
|
|
});
|
|
}
|
|
});
|
|
|
|
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" style="text-align:right;padding:var(--space-2);font-family:var(--font-mono);color:var(--color-text-muted);font-size:var(--text-caption);">${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 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 = `<td style="text-align:center;padding:var(--space-2);"><span style="font-family:var(--font-mono);font-size:var(--text-caption);font-weight:var(--font-weight-bold);color:${color};">${marginPct}%</span></td>`;
|
|
}
|
|
|
|
html += `<tr style="border-bottom:1px solid var(--color-border);cursor:pointer;${i === selectedRow ? 'background:var(--color-primary-muted);' : ''}" onclick="POS.selectRow(${i})">
|
|
<td style="padding:var(--space-2);color:var(--color-text-muted);">${i + 1}</td>
|
|
<td style="padding:var(--space-2);">
|
|
<div style="font-weight:var(--font-weight-semibold);color:var(--color-text-primary);">${item.name}</div>
|
|
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | Stock: ${item.stock}</div>
|
|
</td>
|
|
<td style="text-align:center;padding:var(--space-2);"><input type="number" style="width:50px;text-align:center;font-family:var(--font-mono);background:var(--color-bg-base);color:var(--color-text-primary);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:2px 4px;" value="${item.quantity}" min="1"
|
|
onchange="POS.updateQty(${i}, this.value)" onclick="event.stopPropagation()"></td>
|
|
<td style="text-align:right;padding:var(--space-2);font-family:var(--font-mono);">${fmt(item.unit_price)}</td>
|
|
<td style="text-align:center;padding:var(--space-2);"><input type="number" style="width:45px;text-align:center;font-family:var(--font-mono);background:var(--color-bg-base);color:var(--color-text-primary);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:2px 4px;" value="${item.discount_pct}" min="0" max="100" step="0.5"
|
|
onchange="POS.updateDiscount(${i}, this.value)" onclick="event.stopPropagation()">%</td>
|
|
<td style="text-align:right;padding:var(--space-2);font-family:var(--font-mono);font-weight:var(--font-weight-bold);">${fmt(lineSubtotal)}</td>
|
|
${costHtml}
|
|
${marginHtml}
|
|
<td style="text-align:center;"><button style="background:transparent;border:1px solid var(--color-border);border-radius:var(--radius-sm);width:24px;height:24px;cursor:pointer;color:var(--color-text-muted);font-size:14px;" onclick="event.stopPropagation(); POS.removeFromCart(${i})">×</button></td>
|
|
</tr>`;
|
|
});
|
|
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 = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
|
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
|
} else {
|
|
let html = '';
|
|
data.data.forEach(item => {
|
|
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
|
const price = priceForTier(item.price_1, tier);
|
|
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
|
<div>
|
|
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
|
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
|
|
<div style="font-size:var(--text-caption);color:${item.stock <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};">Stock: ${item.stock}</div>
|
|
</div>
|
|
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div>
|
|
</div>`;
|
|
});
|
|
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,
|
|
price_1: item.price_1,
|
|
price_2: item.price_2,
|
|
price_3: item.price_3,
|
|
});
|
|
if (window.BarcodeFeedback) BarcodeFeedback.success();
|
|
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 = '<div style="padding:var(--space-3);color:var(--color-text-muted);">Sin resultados</div>';
|
|
} else {
|
|
let html = '';
|
|
data.data.forEach(c => {
|
|
const tiers = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' };
|
|
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.selectCustomer(${JSON.stringify(c).replace(/'/g, "'")})'>
|
|
<div style="font-weight:var(--font-weight-semibold);">${c.name}</div>
|
|
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">${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);
|
|
}
|
|
}
|
|
|
|
function recalcCartPrices() {
|
|
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
|
cart.forEach(item => {
|
|
if (item.price_1 > 0) {
|
|
item.unit_price = priceForTier(item.price_1, tier);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function selectCustomer(customer) {
|
|
currentCustomer = customer;
|
|
document.getElementById('customerAutocomplete').style.display = 'none';
|
|
document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';
|
|
|
|
recalcCartPrices();
|
|
|
|
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 =>
|
|
`<button class="quick-btn" onclick="document.getElementById('cashReceived').value=${a};POS.updateChange();">${fmt(a)}</button>`
|
|
).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',
|
|
credito: 'creditPayment',
|
|
cheque: 'chequePayment',
|
|
pendiente: 'pendingPayment',
|
|
};
|
|
|
|
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].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();
|
|
}
|
|
if (method === 'cheque') {
|
|
const chequeAmount = document.getElementById('chequeAmount');
|
|
if (chequeAmount) chequeAmount.value = fmt(getTotal());
|
|
const chequeRef = document.getElementById('chequeRef');
|
|
if (chequeRef) chequeRef.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 = '';
|
|
let saleType = 'cash';
|
|
|
|
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; }
|
|
} else if (paymentMethod === 'credito') {
|
|
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
|
|
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
|
if (total > available) {
|
|
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
|
|
return;
|
|
}
|
|
saleType = 'credit';
|
|
amountPaid = 0;
|
|
} else if (paymentMethod === 'cheque') {
|
|
amountPaid = total;
|
|
reference = document.getElementById('chequeRef').value.trim();
|
|
} else if (paymentMethod === 'pendiente') {
|
|
amountPaid = 0;
|
|
}
|
|
|
|
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: saleType,
|
|
register_id: currentRegister ? currentRegister.id : null,
|
|
amount_paid: amountPaid,
|
|
payment_details: paymentDetails,
|
|
reference: reference,
|
|
generate_cfdi: document.getElementById('cfdiCheck').checked,
|
|
};
|
|
|
|
const zeroItem = findZeroPriceItem();
|
|
if (zeroItem) {
|
|
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
return;
|
|
}
|
|
|
|
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: saleType,
|
|
amount_paid: amountPaid,
|
|
payment_details: paymentDetails,
|
|
reference: reference,
|
|
};
|
|
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;
|
|
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
|
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';
|
|
}
|
|
}
|
|
|
|
// ─── Counter Remission Note ────────────
|
|
async function createRemissionNote() {
|
|
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
|
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
|
|
const zeroItem = findZeroPriceItem();
|
|
if (zeroItem) {
|
|
showToast('No está permitido vender artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
return;
|
|
}
|
|
|
|
const total = getTotal();
|
|
const noteData = {
|
|
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,
|
|
notes: 'Nota de remision generada desde mostrador',
|
|
register_id: currentRegister ? currentRegister.id : null,
|
|
courier_id: selectedCourierId,
|
|
};
|
|
|
|
try {
|
|
const sale = await api('/pos/api/sales/remission', {
|
|
method: 'POST',
|
|
body: JSON.stringify(noteData),
|
|
});
|
|
if (selectedCourierId) {
|
|
const courier = couriers.find(c => c.id === selectedCourierId);
|
|
sale.courier_name = courier ? courier.name : '';
|
|
}
|
|
lastSaleId = sale.id;
|
|
lastSaleData = sale;
|
|
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
|
showTicket(sale);
|
|
cart = [];
|
|
selectedRow = -1;
|
|
clearCustomer();
|
|
selectedCourierId = null;
|
|
const sel = document.getElementById('remissionCourier');
|
|
if (sel) sel.value = '';
|
|
renderCart();
|
|
showToast(`Nota de remision NR-${sale.id} generada`);
|
|
} catch (e) {
|
|
alert('Error al generar nota de remision: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function openPayRemissionModal() {
|
|
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
|
|
document.getElementById('payRemissionFolio').value = '';
|
|
document.getElementById('payRemissionDetail').innerHTML = '';
|
|
document.getElementById('payRemissionResult').innerHTML = '';
|
|
document.getElementById('payRemissionActions').style.display = 'none';
|
|
document.getElementById('payRemissionModal').classList.add('open');
|
|
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
|
|
}
|
|
|
|
function closePayRemissionModal() {
|
|
document.getElementById('payRemissionModal').classList.remove('open');
|
|
}
|
|
|
|
let pendingRemissionToPay = null;
|
|
|
|
async function searchRemissionToPay() {
|
|
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
|
|
if (!folio) { showToast('Ingresa un folio valido'); return; }
|
|
pendingRemissionToPay = null;
|
|
try {
|
|
const sale = await api('/pos/api/sales/' + folio);
|
|
if (sale.status !== 'pending_payment') {
|
|
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
|
|
document.getElementById('payRemissionActions').style.display = 'none';
|
|
return;
|
|
}
|
|
pendingRemissionToPay = sale;
|
|
let itemsHtml = (sale.items || []).map(it => `
|
|
<div class="ticket-line">
|
|
<span class="qty">${it.quantity}</span>
|
|
<span class="name">${it.name || ''}</span>
|
|
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
|
|
</div>
|
|
`).join('');
|
|
document.getElementById('payRemissionDetail').innerHTML = `
|
|
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
|
|
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
|
|
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
|
|
<hr class="divider">
|
|
${itemsHtml}
|
|
`;
|
|
document.getElementById('payRemissionActions').style.display = '';
|
|
} catch (e) {
|
|
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
|
document.getElementById('payRemissionActions').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function confirmPayRemission() {
|
|
if (!pendingRemissionToPay) return;
|
|
const sale = pendingRemissionToPay;
|
|
const paymentMethod = document.getElementById('payRemissionMethod').value;
|
|
let amountPaid = parseFloat(sale.total);
|
|
let paymentDetails = [];
|
|
let reference = '';
|
|
|
|
if (paymentMethod === 'efectivo') {
|
|
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
|
|
if (received < sale.total) { alert('Monto insuficiente'); return; }
|
|
amountPaid = received;
|
|
} else if (paymentMethod === 'mixto') {
|
|
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
|
|
let sum = 0;
|
|
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 });
|
|
sum += amount;
|
|
}
|
|
});
|
|
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
|
|
amountPaid = sum;
|
|
} else {
|
|
reference = document.getElementById('payRemissionReference').value.trim();
|
|
}
|
|
|
|
try {
|
|
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
payment_method: paymentMethod,
|
|
amount_paid: amountPaid,
|
|
payment_details: paymentDetails,
|
|
register_id: currentRegister ? currentRegister.id : null,
|
|
reference: reference,
|
|
}),
|
|
});
|
|
closePayRemissionModal();
|
|
showToast(`Nota NR-${sale.id} pagada`);
|
|
// Refresh sale object to print paid ticket
|
|
const updated = await api('/pos/api/sales/' + sale.id);
|
|
lastSaleId = updated.id;
|
|
lastSaleData = updated;
|
|
showTicket(updated);
|
|
} catch (e) {
|
|
alert('Error al cobrar nota: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function updatePayRemissionMethod() {
|
|
const method = document.getElementById('payRemissionMethod').value;
|
|
const cashEl = document.getElementById('payRemissionCash');
|
|
const refEl = document.getElementById('payRemissionRef');
|
|
const mixedEl = document.getElementById('payRemissionMixed');
|
|
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
|
|
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
|
|
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
|
|
}
|
|
|
|
// ─── Quotation ───────────────────────
|
|
async function saveQuotation() {
|
|
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
|
const zeroItem = findZeroPriceItem();
|
|
if (zeroItem) {
|
|
showToast('No está permitido cotizar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
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 (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
|
|
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
|
const zeroItem = findZeroPriceItem();
|
|
if (zeroItem) {
|
|
alert('No está permitido apartar artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ─── Service Order from POS ──────────
|
|
function createServiceOrder() {
|
|
if (!workshopModuleEnabled) { showToast('El módulo de taller no está habilitado'); return; }
|
|
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
|
|
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
|
const zeroItem = findZeroPriceItem();
|
|
if (zeroItem) {
|
|
showToast('No está permitido generar orden con artículos en $0: ' + (zeroItem.name || zeroItem.part_number));
|
|
return;
|
|
}
|
|
const custInput = document.getElementById('soCustomer');
|
|
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
|
|
document.getElementById('serviceOrderModal').classList.add('open');
|
|
}
|
|
|
|
function closeServiceOrderModal() {
|
|
document.getElementById('serviceOrderModal').classList.remove('open');
|
|
}
|
|
|
|
async function confirmServiceOrder() {
|
|
const customerId = currentCustomer ? currentCustomer.id : null;
|
|
if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; }
|
|
|
|
const vehicleText = document.getElementById('soVehicle').value.trim();
|
|
const delivery = document.getElementById('soDelivery').value;
|
|
const notes = document.getElementById('soNotes').value.trim();
|
|
const isDirect = document.getElementById('soDirect').checked;
|
|
|
|
const items = cart.map(item => ({
|
|
inventory_id: item.inventory_id,
|
|
part_number: item.part_number,
|
|
name: item.name,
|
|
quantity: item.quantity,
|
|
unit_price: item.unit_price,
|
|
status: 'pending'
|
|
}));
|
|
|
|
try {
|
|
const order = await api('/pos/api/service-orders/from-pos', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
customer_id: customerId,
|
|
priority: 'normal',
|
|
reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''),
|
|
delivery_method: delivery || null,
|
|
is_direct: isDirect,
|
|
items: items
|
|
})
|
|
});
|
|
|
|
showToast(`Orden de servicio ${order.order_number} creada`);
|
|
closeServiceOrderModal();
|
|
cart = [];
|
|
selectedRow = -1;
|
|
renderCart();
|
|
showServiceOrderTicket(order);
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function showServiceOrderTicket(order) {
|
|
const dateStr = new Date(order.created_at).toLocaleString('es-MX', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit'
|
|
});
|
|
|
|
const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
|
let itemsHtml = '';
|
|
(order.items || []).forEach(item => {
|
|
const itemTotal = item.unit_price * item.quantity;
|
|
itemsHtml += `
|
|
<div class="item-line-wide ticket-line">
|
|
<span class="qty">${item.quantity}</span>
|
|
<span class="name">${item.name || ''}</span>
|
|
<span class="price">${fmt(item.unit_price)}</span>
|
|
<span class="subtotal">${fmt(itemTotal)}</span>
|
|
</div>`;
|
|
});
|
|
|
|
const ticketHtml = `
|
|
<div class="store-name">NEXUS AUTOPARTS</div>
|
|
<div class="store-tagline">Orden de servicio</div>
|
|
<div class="store-info">
|
|
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
|
|
RFC: NAU210315XX1
|
|
</div>
|
|
<hr class="divider-double">
|
|
<div class="folio-line">
|
|
<span>ORDEN: ${order.order_number}</span>
|
|
<span>${dateStr}</span>
|
|
</div>
|
|
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
|
<span>Cliente: ${customerName}</span>
|
|
</div>
|
|
<hr class="divider">
|
|
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
|
<span class="qty">Cant</span>
|
|
<span class="name">Descripcion</span>
|
|
<span class="price">P. Unit</span>
|
|
<span class="subtotal">Importe</span>
|
|
</div>
|
|
<hr class="divider" style="margin: 2px 0;">
|
|
${itemsHtml}
|
|
<hr class="divider-double">
|
|
<div class="total-section">
|
|
<div class="total-line">
|
|
<span>Refacciones:</span><span>${fmt(order.total_parts || 0)}</span>
|
|
</div>
|
|
<div class="total-line">
|
|
<span>Mano de obra:</span><span>${fmt(order.total_labor || 0)}</span>
|
|
</div>
|
|
<div class="total-line grand">
|
|
<span>TOTAL:</span><span>${fmt(order.total || 0)}</span>
|
|
</div>
|
|
</div>
|
|
<hr class="divider">
|
|
<div class="footer-section">
|
|
<div class="thanks">Gracias por su preferencia!</div>
|
|
<div>Conserve su ticket como comprobante.</div>
|
|
</div>
|
|
`;
|
|
|
|
const printArea = document.getElementById('ticketContent');
|
|
if (printArea) printArea.innerHTML = ticketHtml;
|
|
const preview = document.getElementById('ticketPreviewContent');
|
|
if (preview) preview.innerHTML = ticketHtml;
|
|
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
|
if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio';
|
|
document.getElementById('ticketModal').classList.add('open');
|
|
}
|
|
|
|
// ─── 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 isRemission = sale.status === 'pending_payment';
|
|
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
|
const customerRfc = sale.customer_rfc || (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 += `
|
|
<div class="item-line-wide ticket-line">
|
|
<span class="qty">${item.quantity}</span>
|
|
<span class="name">${item.name || ''}</span>
|
|
<span class="price">${fmt(item.unit_price)}</span>
|
|
<span class="subtotal">${fmt(item.subtotal || itemTotal)}</span>
|
|
</div>`;
|
|
});
|
|
|
|
const cfg = receiptConfig || {};
|
|
const showLogo = cfg.show_logo && cfg.logo;
|
|
const showRfc = cfg.show_rfc !== false;
|
|
const showAddress = cfg.show_address;
|
|
const showPhone = cfg.show_phone;
|
|
const showIva = cfg.show_iva_breakdown !== false;
|
|
const showPayment = cfg.show_payment_details !== false;
|
|
const showEmployee = cfg.show_employee;
|
|
const storeName = rc('store_name', 'NEXUS AUTOPARTS');
|
|
const storeTagline = rc('tagline', 'Tu conexion con las refacciones');
|
|
const storeRfc = rc('rfc', 'NAU210315XX1');
|
|
const storeAddress = rc('address', '');
|
|
const storePhone = rc('phone', '');
|
|
const thanksMsg = rc('thanks_message', 'Gracias por su compra!');
|
|
const footerMsg = rc('footer', 'Conserve su ticket como comprobante.');
|
|
|
|
let storeInfoLines = [];
|
|
if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`);
|
|
if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`);
|
|
if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`);
|
|
if (showAddress && storeAddress) storeInfoLines.push(storeAddress);
|
|
if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`);
|
|
|
|
const logoHtml = showLogo ? `<div class="ticket-logo-wrap"><img src="${cfg.logo}" alt="Logo" class="ticket-logo" style="max-width:140px;max-height:70px;object-fit:contain;"></div>` : '';
|
|
|
|
const ticketHtml = `
|
|
${logoHtml}
|
|
<div class="store-name">${storeName}</div>
|
|
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
|
|
<div class="store-info">
|
|
${storeInfoLines.join('<br>')}
|
|
</div>
|
|
<hr class="divider-double">
|
|
<div class="folio-line">
|
|
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
|
|
<span>${dateStr}</span>
|
|
</div>
|
|
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
|
<span>Cliente: ${customerName}</span>
|
|
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
|
</div>
|
|
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
|
<hr class="divider">
|
|
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
|
<span class="qty">Cant</span>
|
|
<span class="name">Descripcion</span>
|
|
<span class="price">P. Unit</span>
|
|
<span class="subtotal">Importe</span>
|
|
</div>
|
|
<hr class="divider" style="margin: 2px 0;">
|
|
${itemsHtml}
|
|
<hr class="divider-double">
|
|
<div class="total-section">
|
|
<div class="total-line">
|
|
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
|
|
</div>
|
|
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
|
|
${showIva ? `<div class="total-line"><span>IVA:</span><span>${fmt(sale.tax_total)}</span></div>` : ''}
|
|
<div class="total-line grand">
|
|
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
|
|
</div>
|
|
</div>
|
|
${showPayment ? `
|
|
<hr class="divider">
|
|
<div class="payment-section">
|
|
${isRemission ? `
|
|
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
|
|
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
|
|
</div>
|
|
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
|
|
` : `
|
|
<div class="ticket-row">
|
|
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
|
|
</div>
|
|
${sale.payment_method === 'efectivo' ? `
|
|
<div class="ticket-row">
|
|
<span>Recibido:</span><span>${fmt(sale.amount_paid)}</span>
|
|
</div>
|
|
<div class="ticket-row" style="font-weight: bold;">
|
|
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
|
|
</div>` : ''}
|
|
`}
|
|
</div>` : ''}
|
|
<hr class="divider">
|
|
<div class="footer-section">
|
|
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
|
|
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
|
|
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
|
|
</div>
|
|
`;
|
|
|
|
// 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;
|
|
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
|
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
|
|
|
|
document.getElementById('ticketModal').classList.add('open');
|
|
}
|
|
|
|
function closeTicketModal() {
|
|
document.getElementById('ticketModal').classList.remove('open');
|
|
}
|
|
|
|
function printTicket() {
|
|
const src = document.getElementById('ticketContent');
|
|
if (!src) return;
|
|
|
|
// Paper width: 58 mm -> 48 mm printable; 80 mm -> 72 mm printable.
|
|
const paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
|
|
const printWidth = paperWidth === 58 ? 48 : 72;
|
|
|
|
let iframe = document.getElementById('ticketPrintFrame');
|
|
if (!iframe) {
|
|
iframe = document.createElement('iframe');
|
|
iframe.id = 'ticketPrintFrame';
|
|
iframe.style.position = 'fixed';
|
|
iframe.style.left = '-9999px';
|
|
iframe.style.top = '0';
|
|
iframe.style.width = printWidth + 'mm';
|
|
iframe.style.border = '0';
|
|
document.body.appendChild(iframe);
|
|
}
|
|
|
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
|
doc.open();
|
|
doc.write(`<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
@page { margin: 0; size: ${printWidth}mm auto; }
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; height: auto; overflow: visible; background: #fff; color: #000; font-family: 'Courier New', Consolas, monospace; font-size: 9pt; }
|
|
body { width: ${printWidth}mm; }
|
|
.ticket { width: ${printWidth}mm; padding: 3mm; border: none !important; box-shadow: none !important; }
|
|
.item-line-wide { display: grid !important; grid-template-columns: auto 1fr auto auto !important; gap: 2mm; font-size: 8pt; margin-bottom: 1mm; }
|
|
.item-line-wide .name { white-space: normal !important; word-break: break-word; overflow: visible; }
|
|
.ticket-row, .folio-line, .total-line { display: flex !important; justify-content: space-between; }
|
|
.total-line.grand { font-size: 11pt; font-weight: bold; border-top: 0.5mm solid #000; padding-top: 1mm; margin-top: 1mm; }
|
|
.store-name { font-size: 12pt; font-weight: bold; text-align: center; margin-bottom: 2px; }
|
|
.store-tagline, .store-info { font-size: 8pt; text-align: center; color: #333; margin-bottom: 4px; line-height: 1.3; }
|
|
.divider { border: none; border-top: 1px dashed #999; margin: 4px 0; }
|
|
.divider-double { border: none; border-top: 2px solid #333; margin: 4px 0; }
|
|
.footer-section { text-align: center; margin-top: 6px; font-size: 8pt; color: #555; }
|
|
img { max-width: 100%; height: auto; }
|
|
</style>
|
|
</head>
|
|
<body>${src.innerHTML}</body>
|
|
</html>`);
|
|
doc.close();
|
|
|
|
// Measure real content height and set exact page size (avoids phantom 3276 mm).
|
|
const bodyEl = doc.body;
|
|
const heightPx = Math.max(bodyEl.scrollHeight, bodyEl.offsetHeight);
|
|
const heightMm = Math.ceil((heightPx * 25.4 / 96) + 5);
|
|
|
|
const exactPage = doc.createElement('style');
|
|
exactPage.textContent = `@page { margin: 0; size: ${printWidth}mm ${heightMm}mm; }`;
|
|
doc.head.appendChild(exactPage);
|
|
|
|
iframe.contentWindow.focus();
|
|
setTimeout(() => { iframe.contentWindow.print(); }, 150);
|
|
}
|
|
|
|
// ─── 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 paperWidth = parseInt(rc('paper_width', '80'), 10) || 80;
|
|
const ok = await NexusPrinter.printSale(lastSaleId, paperWidth);
|
|
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 'F7':
|
|
e.preventDefault();
|
|
createServiceOrder();
|
|
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.getElementById('serviceOrderModal').classList.contains('open')) {
|
|
closeServiceOrderModal();
|
|
} 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;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Register Cmd+K items
|
|
if (typeof registerCmdKItem === "function") {
|
|
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
|
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
|
|
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
|
|
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
|
}
|
|
|
|
// ─── Public API ──────────────────────
|
|
init();
|
|
|
|
return {
|
|
addToCart, removeFromCart, clearCart, selectRow,
|
|
updateQty, updateDiscount,
|
|
addFromSearch, hideSearchResults,
|
|
selectCustomer, clearCustomer,
|
|
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
|
checkout, confirmPayment, closePaymentModal,
|
|
selectPaymentMethod, updateChange, updateMixedTotal,
|
|
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
|
|
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
|
|
saveQuotation, createLayaway,
|
|
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
|
showLastSale, openDrawer,
|
|
showTicket, closeTicketModal, printTicket,
|
|
connectThermal, thermalPrint,
|
|
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
|
|
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
|
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
|
|
};
|
|
|
|
})();
|