// /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' : ''}`;
}
// Discount
const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Re-wire action buttons after detail panel is visible
wireActionButtons();
// 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/sale?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 = '';
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
safeSet('fName', '');
safeSet('fRfc', '');
safeSet('fRazonSocial', '');
safeSet('fRegimenFiscal', '');
safeSet('fUsoCfdi', 'G03');
safeSet('fCp', '');
safeSet('fPhone', '');
safeSet('fEmail', '');
safeSet('fAddress', '');
safeSet('fPriceTier', '1');
safeSet('fCreditLimit', '0');
safeSet('fMaxDiscountPct', '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;
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
safeSet('fName', c.name || '');
safeSet('fRfc', c.rfc || '');
safeSet('fRazonSocial', c.razon_social || '');
safeSet('fRegimenFiscal', c.regimen_fiscal || '');
safeSet('fUsoCfdi', c.uso_cfdi || 'G03');
safeSet('fCp', c.cp || '');
safeSet('fPhone', c.phone || '');
safeSet('fEmail', c.email || '');
safeSet('fAddress', c.address || '');
safeSet('fPriceTier', c.price_tier || '1');
safeSet('fCreditLimit', c.credit_limit || 0);
safeSet('fMaxDiscountPct', c.max_discount_pct || 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,
max_discount_pct: parseFloat(val('fMaxDiscountPct')) || 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 += '| Fecha | Concepto | Cargo | Abono | Saldo |
';
data.entries.forEach(e => {
html += `
| ${formatDate(e.date)} |
${e.description || ''} |
${e.type === 'charge' ? fmt(e.amount) : ''} |
${e.type === 'payment' ? fmt(e.amount) : ''} |
${fmt(e.running_balance)} |
`;
});
html += '
';
}
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
// Always remove and re-inject to ensure latest fields are present
const existingModal = document.getElementById('customerModal');
if (existingModal) existingModal.remove();
const div = document.createElement('div');
div.innerHTML = `
`;
document.body.appendChild(div);
// Statement Modal
const existingStatement = document.getElementById('statementModal');
if (existingStatement) existingStatement.remove();
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();
const publicApi = {
search, goToPage, loadCustomers,
showDetail, selectCustomer, closeDetail,
showCreateModal, editCurrent, closeModal, save,
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
};
// Expose globally for inline HTML onclick handlers
window.Customers = publicApi;
return publicApi;
})();