// /home/Autopartes/pos/static/js/customers.js /** * Customers management frontend. * Communicates with /pos/api/customers (customers_bp). * Wired to the design-system HTML (customers.html). */ const Customers = (() => { let token = localStorage.getItem('pos_token') || ''; let currentPage = 1; let totalPages = 1; let currentCustomer = null; let searchTimeout = null; const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtShort = (n) => { n = parseFloat(n || 0); if (n >= 1000000) return '$' + (n / 1000000).toFixed(1) + 'M'; if (n >= 1000) return '$' + (n / 1000).toFixed(0) + 'K'; return '$' + n.toFixed(0); }; function headers() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; } async function api(url, options = {}) { options.headers = headers(); const res = await fetch(url, options); if (res.status === 401) { window.location.href = '/pos/login'; return; } const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } // ─── Tier / Status helpers ─────────── const tierMap = { 1: 'Mostrador', 2: 'Taller', 3: 'Mayoreo' }; const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' }; function statusBadge(c) { // Derive status: if credit_balance > credit_limit => Mora, else Activo if (c.credit_balance > 0 && c.credit_limit > 0 && c.credit_balance > c.credit_limit) { return 'Mora'; } return 'Activo'; } function getInitials(name) { if (!name) return '--'; const parts = name.trim().split(/\s+/); if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); return name.substring(0, 2).toUpperCase(); } function formatDate(d) { if (!d) return '-'; try { return new Date(d).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }); } catch (_) { return d; } } // ─── Summary Cards ────────────────── async function loadSummary() { try { // Load a small page just to get pagination totals const data = await api('/pos/api/customers?page=1&per_page=1'); const totalClientes = data.pagination ? data.pagination.total : 0; const cards = document.querySelectorAll('.summary-card__value'); if (cards.length >= 1) cards[0].textContent = totalClientes.toLocaleString('es-MX'); } catch (_) { /* non-critical */ } } // ─── List ──────────────────────────── async function loadCustomers(page, q) { page = page || currentPage; const searchEl = document.getElementById('searchInput'); q = q !== undefined ? q : (searchEl ? searchEl.value || '' : ''); try { const params = new URLSearchParams({ page, per_page: 50 }); if (q) params.append('q', q); const data = await api(`/pos/api/customers?${params}`); renderTable(data.data || []); renderPagination(data.pagination || {}); } catch (e) { console.error('Load customers failed:', e); } } function renderCustomerRow(c) { const tier = tierMap[c.price_tier] || 'Mostrador'; const tClass = tierClass[c.price_tier] || 'mostrador'; const limit = parseFloat(c.credit_limit || 0); const balance = parseFloat(c.credit_balance || 0); const available = Math.max(0, limit - balance); const usedPct = limit > 0 ? Math.round((balance / limit) * 100) : 0; const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : ''; const num = String(c.id).padStart(5, '0'); const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : ''; return '' + '' + num + '' + '' + '
' + (c.name || '') + '
' + '
' + (c.email || '') + '
' + '' + '' + (c.rfc || '-') + '' + '' + (c.phone || '-') + '' + '' + (c.email || '-') + '' + '' + tier + '' + '' + fmt(available) + '' + '' + formatDate(c.last_purchase || c.created_at) + '' + '' + statusBadge(c) + '' + ''; } var customersVS = null; function renderTable(customers) { const tbody = document.getElementById('customersBody'); if (!tbody) return; if (!customers || customers.length === 0) { tbody.innerHTML = 'Sin resultados.'; return; } if (!customersVS) { customersVS = new VirtualScroll({ container: tbody, rowHeight: 52, buffer: 3, renderRow: renderCustomerRow, emptyHtml: 'Sin resultados.' }); } customersVS.setData(customers); } function renderPagination(pag) { const container = document.querySelector('.pagination'); const info = document.getElementById('tableInfo'); if (!pag || !pag.total_pages) { if (container) container.innerHTML = ''; if (info) info.textContent = ''; return; } totalPages = pag.total_pages; currentPage = pag.page; const total = pag.total || 0; const perPage = pag.per_page || 50; const start = (pag.page - 1) * perPage + 1; const end = Math.min(pag.page * perPage, total); if (info) info.textContent = `Mostrando ${start}–${end} de ${total.toLocaleString('es-MX')} clientes`; if (!container) return; if (totalPages <= 1) { container.innerHTML = ''; return; } let html = ''; html += ``; const maxButtons = 7; let startP = Math.max(1, pag.page - 3); let endP = Math.min(totalPages, startP + maxButtons - 1); if (endP - startP < maxButtons - 1) startP = Math.max(1, endP - maxButtons + 1); for (let i = startP; i <= endP; i++) { html += ``; } if (endP < totalPages) { html += '...'; html += ``; } html += ``; container.innerHTML = html; } function goToPage(page) { if (page < 1 || page > totalPages) return; currentPage = page; loadCustomers(page); } function search() { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { currentPage = 1; loadCustomers(1); }, 300); } // Alias for inline HTML oninput="filterCustomers()" window.filterCustomers = function() { search(); }; // ─── Detail Panel (right side) ────── async function selectCustomer(id) { try { const c = await api(`/pos/api/customers/${id}`); currentCustomer = c; // Toggle empty/content const emptyEl = document.getElementById('detailEmpty'); const contentEl = document.getElementById('detailContent'); if (emptyEl) emptyEl.style.display = 'none'; if (contentEl) contentEl.style.display = 'flex'; // Avatar & Header const avatarEl = document.getElementById('detailAvatar'); if (avatarEl) avatarEl.textContent = getInitials(c.name); const nameEl = document.getElementById('detailName'); if (nameEl) nameEl.textContent = (c.name || '').toUpperCase(); const rfcEl = document.getElementById('detailRFC'); if (rfcEl) rfcEl.textContent = c.rfc || '-'; // Tipo chip const tipoEl = document.getElementById('detailTipo'); if (tipoEl) { const tier = tierMap[c.price_tier] || 'Mostrador'; const tClass = tierClass[c.price_tier] || 'mostrador'; tipoEl.textContent = tier; tipoEl.className = `tipo-chip tipo-chip--${tClass}`; } // Status badge const statEl = document.getElementById('detailStatus'); if (statEl) statEl.innerHTML = statusBadge(c); // Contact const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val || '-'; }; set('detailAddress', c.address); set('detailPhone', c.phone); set('detailEmail', c.email); set('detailSince', formatDate(c.created_at)); set('detailLastPurchase', formatDate(c.last_purchase)); // Credit const limit = parseFloat(c.credit_limit || 0); const balance = parseFloat(c.credit_balance || 0); const available = Math.max(0, limit - balance); const pct = limit > 0 ? Math.round((balance / limit) * 100) : 0; set('detailCreditLimit', fmt(limit)); set('detailCreditAvail', fmt(available)); set('detailCreditUsed', fmt(balance)); set('detailCreditPct', pct + '%'); const bar = document.getElementById('detailCreditBar'); if (bar) { bar.style.width = pct + '%'; bar.className = `progress-bar__fill ${pct < 40 ? 'low' : pct > 75 ? 'high' : ''}`; } // Purchase History const hbody = document.getElementById('historyBody'); if (hbody) { const purchases = c.recent_purchases || []; if (purchases.length === 0) { hbody.innerHTML = 'Sin compras recientes'; } else { hbody.innerHTML = ''; purchases.forEach(p => { const statusClass = p.status === 'paid' ? 'mbadge--paid' : p.status === 'overdue' ? 'mbadge--overdue' : 'mbadge--pending'; const statusLabel = p.status === 'paid' ? 'Pagado' : p.status === 'overdue' ? 'Vencido' : 'Pendiente'; const tr = document.createElement('tr'); tr.innerHTML = ` ${formatDate(p.created_at)} NX-${String(p.id).padStart(5, '0')} ${fmt(p.total)} ${statusLabel} `; hbody.appendChild(tr); }); } } // Also populate slide panel fields populateSlidePanel(c); // Re-render table to highlight selected row loadCustomers(currentPage); } catch (e) { console.error('Error loading customer:', e); } } // Populate the slide-panel (mobile/alt detail view) function populateSlidePanel(c) { const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val || '--'; }; set('panelAvatar', getInitials(c.name)); set('panelName', c.name); set('panelRfc', 'RFC: ' + (c.rfc || '-')); const limit = parseFloat(c.credit_limit || 0); const balance = parseFloat(c.credit_balance || 0); const available = Math.max(0, limit - balance); const pct = limit > 0 ? Math.round((balance / limit) * 100) : 0; set('panelCreditLimit', fmt(limit)); set('panelCreditUsed', fmt(balance)); set('panelCreditAvail', fmt(available)); const panelBar = document.getElementById('panelCreditBar'); if (panelBar) panelBar.style.width = pct + '%'; // Vehicles const vehiclesEl = document.getElementById('panelVehicles'); if (vehiclesEl) { const vehicles = c.vehicle_info || []; if (vehicles.length === 0) { vehiclesEl.innerHTML = 'Sin vehiculos registrados'; } else { vehiclesEl.innerHTML = vehicles.map(v => `
${v.make || ''} ${v.model || ''} ${v.year || ''} ${v.plates ? ` Placas: ${v.plates}` : ''}
` ).join(''); } } // Purchases const purchasesEl = document.getElementById('panelPurchases'); if (purchasesEl) { const purchases = c.recent_purchases || []; if (purchases.length === 0) { purchasesEl.innerHTML = 'Sin compras recientes'; } else { purchasesEl.innerHTML = purchases.slice(0, 5).map(p => `
NX-${String(p.id).padStart(5, '0')} — ${formatDate(p.created_at)} ${fmt(p.total)}
` ).join(''); } } } function closeDetail() { currentCustomer = null; const emptyEl = document.getElementById('detailEmpty'); const contentEl = document.getElementById('detailContent'); if (emptyEl) emptyEl.style.display = 'flex'; if (contentEl) contentEl.style.display = 'none'; loadCustomers(currentPage); } // Wire action buttons in detail panel function wireActionButtons() { const btns = document.querySelectorAll('.quick-actions .action-btn'); // Order: Nueva Venta, Editar, Estado de Cuenta, Historial if (btns.length >= 1) btns[0].onclick = () => { if (currentCustomer) window.location.href = '/pos/?customer=' + currentCustomer.id; }; if (btns.length >= 2) btns[1].onclick = () => editCurrent(); if (btns.length >= 3) btns[2].onclick = () => showStatement(); if (btns.length >= 4) btns[3].onclick = () => { if (currentCustomer) selectCustomer(currentCustomer.id); }; } // ─── Create/Edit Modal ─────────────── function showCreateModal() { const modal = document.getElementById('customerModal'); if (!modal) return; document.getElementById('modalTitle').textContent = 'Nuevo Cliente'; document.getElementById('editId').value = ''; document.getElementById('fName').value = ''; document.getElementById('fRfc').value = ''; document.getElementById('fRazonSocial').value = ''; document.getElementById('fRegimenFiscal').value = ''; document.getElementById('fUsoCfdi').value = 'G03'; document.getElementById('fCp').value = ''; document.getElementById('fPhone').value = ''; document.getElementById('fEmail').value = ''; document.getElementById('fAddress').value = ''; document.getElementById('fPriceTier').value = '1'; document.getElementById('fCreditLimit').value = '0'; modal.classList.add('active'); document.getElementById('fName').focus(); } function editCurrent() { if (!currentCustomer) return; const c = currentCustomer; const modal = document.getElementById('customerModal'); if (!modal) return; document.getElementById('modalTitle').textContent = 'Editar Cliente'; document.getElementById('editId').value = c.id; document.getElementById('fName').value = c.name || ''; document.getElementById('fRfc').value = c.rfc || ''; document.getElementById('fRazonSocial').value = c.razon_social || ''; document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || ''; document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03'; document.getElementById('fCp').value = c.cp || ''; document.getElementById('fPhone').value = c.phone || ''; document.getElementById('fEmail').value = c.email || ''; document.getElementById('fAddress').value = c.address || ''; document.getElementById('fPriceTier').value = c.price_tier || '1'; document.getElementById('fCreditLimit').value = c.credit_limit || 0; modal.classList.add('active'); } function closeModal() { const modal = document.getElementById('customerModal'); if (modal) modal.classList.remove('active'); } async function save() { const nameEl = document.getElementById('fName'); const name = nameEl ? nameEl.value.trim() : ''; if (!name) { alert('Nombre es requerido'); return; } const val = (id) => { const el = document.getElementById(id); return el ? el.value.trim() : ''; }; const body = { name: name, rfc: val('fRfc') || null, razon_social: val('fRazonSocial') || null, regimen_fiscal: val('fRegimenFiscal') || null, uso_cfdi: val('fUsoCfdi') || 'G03', cp: val('fCp') || null, phone: val('fPhone') || null, email: val('fEmail') || null, address: val('fAddress') || null, price_tier: parseInt(val('fPriceTier')) || 1, credit_limit: parseFloat(val('fCreditLimit')) || 0, }; const editId = val('editId'); try { if (editId) { await api(`/pos/api/customers/${editId}`, { method: 'PUT', body: JSON.stringify(body), }); } else { await api('/pos/api/customers', { method: 'POST', body: JSON.stringify(body), }); } closeModal(); loadCustomers(); if (editId && currentCustomer) { selectCustomer(editId); } } catch (e) { alert('Error: ' + e.message); } } // ─── Statement Modal ───────────────── async function showStatement() { if (!currentCustomer) return; const modal = document.getElementById('statementModal'); if (!modal) return; const nameEl = document.getElementById('statementName'); if (nameEl) nameEl.textContent = currentCustomer.name; const content = document.getElementById('statementContent'); if (content) content.innerHTML = '
Cargando...
'; modal.classList.add('active'); try { const data = await api(`/pos/api/customers/${currentCustomer.id}/statement`); let html = `
Saldo actual: ${fmt(data.balance)} | Limite: ${fmt(data.customer ? data.customer.credit_limit : 0)}
`; if (!data.entries || data.entries.length === 0) { html += '
Sin movimientos
'; } else { html += ''; html += ''; data.entries.forEach(e => { html += ``; }); html += '
FechaConceptoCargoAbonoSaldo
${formatDate(e.date)} ${e.description || ''} ${e.type === 'charge' ? fmt(e.amount) : ''} ${e.type === 'payment' ? fmt(e.amount) : ''} ${fmt(e.running_balance)}
'; } if (content) content.innerHTML = html; } catch (e) { if (content) content.innerHTML = `
Error: ${e.message}
`; } } function closeStatement() { const modal = document.getElementById('statementModal'); if (modal) modal.classList.remove('active'); } // ─── Payment Modal ─────────────────── function showPaymentModal() { if (!currentCustomer) return; const modal = document.getElementById('paymentModal'); if (!modal) return; document.getElementById('paymentCustomerName').textContent = currentCustomer.name; document.getElementById('paymentAmount').value = ''; document.getElementById('paymentMethod').value = 'cash'; document.getElementById('paymentRef').value = ''; modal.classList.add('active'); document.getElementById('paymentAmount').focus(); } function closePayment() { const modal = document.getElementById('paymentModal'); if (modal) modal.classList.remove('active'); } async function recordPayment() { if (!currentCustomer) return; const amount = parseFloat(document.getElementById('paymentAmount').value); if (!amount || amount <= 0) { alert('Ingresa un monto valido'); return; } const method = document.getElementById('paymentMethod').value; const reference = document.getElementById('paymentRef').value.trim(); try { await api(`/pos/api/customers/${currentCustomer.id}/payment`, { method: 'POST', body: JSON.stringify({ amount, method, reference }), }); closePayment(); selectCustomer(currentCustomer.id); } catch (e) { alert('Error: ' + e.message); } } // ─── Slide Panel (from HTML) ───────── // The HTML already has openSlidePanel/closeSlidePanel. We hook showDetail into it. function showDetail(id) { selectCustomer(id); if (typeof openSlidePanel === 'function') openSlidePanel(); } // ─── Init ──────────────────────────── function init() { // Auth check if (!token) { window.location.href = '/pos/login'; return; } // Wire the "Nuevo Cliente" button from the page header window.openNewCustomerModal = showCreateModal; // Wire action buttons in detail panel wireActionButtons(); // Wire the inline HTML viewCustomer to our showDetail window.viewCustomer = showDetail; // Inject modals if not present injectModals(); // Load data loadCustomers(); loadSummary(); } function injectModals() { // Customer Create/Edit Modal if (!document.getElementById('customerModal')) { const div = document.createElement('div'); div.innerHTML = ` `; document.body.appendChild(div); } // Statement Modal if (!document.getElementById('statementModal')) { const div = document.createElement('div'); div.innerHTML = ` `; document.body.appendChild(div); } // Payment Modal if (!document.getElementById('paymentModal')) { const div = document.createElement('div'); div.innerHTML = ` `; document.body.appendChild(div); } // Inject modal styles if (!document.getElementById('modalStyles')) { const style = document.createElement('style'); style.id = 'modalStyles'; style.textContent = ` .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 9000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); } .modal-overlay.active { display: flex !important; } .modal-box { background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: var(--radius-lg); width: 90%; max-width: 600px; max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow-xl); } .modal-box--wide { max-width: 800px; } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--color-border); } .modal-header h3 { font-family: var(--font-heading); font-size: var(--text-h6); font-weight: var(--heading-weight-primary); color: var(--color-text-primary); letter-spacing: var(--tracking-wide); text-transform: uppercase; } .modal-body { padding: var(--space-5); } .modal-footer { display: flex; justify-content: flex-end; gap: var(--space-3); padding: var(--space-4) var(--space-5); border-top: 1px solid var(--color-border); } .form-row { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); } .form-row > .form-group { flex: 1; } .form-group { margin-bottom: var(--space-4); } .form-group label { display: block; font-size: var(--text-caption); font-weight: var(--font-weight-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: var(--space-1); } .form-input { width: 100%; height: 38px; padding: 0 var(--space-3); background: var(--color-bg-overlay); border: 1.5px solid var(--color-border); border-radius: var(--radius-md); font-family: var(--font-body); font-size: var(--text-body-sm); color: var(--color-text-primary); outline: none; transition: var(--transition-fast); } .form-input:focus { border-color: var(--color-border-focus); box-shadow: var(--shadow-focus); } select.form-input { cursor: pointer; } `; document.head.appendChild(style); } } // Run init init(); return { search, goToPage, loadCustomers, showDetail, selectCustomer, closeDetail, showCreateModal, editCurrent, closeModal, save, showStatement, closeStatement, showPaymentModal, closePayment, recordPayment, }; })();