feat(pos): add customers page — search, credit, vehicles, statements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
307
pos/static/js/customers.js
Normal file
307
pos/static/js/customers.js
Normal file
@@ -0,0 +1,307 @@
|
||||
// /home/Autopartes/pos/static/js/customers.js
|
||||
/**
|
||||
* Customers management frontend.
|
||||
* Communicates with /pos/api/customers (customers_bp).
|
||||
*/
|
||||
const Customers = (() => {
|
||||
let token = localStorage.getItem('pos_token') || '';
|
||||
let currentPage = 1;
|
||||
let currentCustomer = null;
|
||||
let searchTimeout = null;
|
||||
|
||||
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
options.headers = headers();
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── List ────────────────────────────
|
||||
async function loadCustomers(page, q) {
|
||||
page = page || currentPage;
|
||||
q = q !== undefined ? q : (document.getElementById('searchInput').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 renderTable(customers) {
|
||||
const tbody = document.getElementById('customersBody');
|
||||
const tiers = { 1: ['Mostrador', 'tier-1'], 2: ['Taller', 'tier-2'], 3: ['Mayoreo', 'tier-3'] };
|
||||
|
||||
let html = '';
|
||||
customers.forEach(c => {
|
||||
const [tierName, tierClass] = tiers[c.price_tier] || ['P1', 'tier-1'];
|
||||
const limit = c.credit_limit || 0;
|
||||
const balance = c.credit_balance || 0;
|
||||
const usagePct = limit > 0 ? Math.min(100, (balance / limit) * 100) : 0;
|
||||
const fillClass = usagePct > 90 ? 'danger' : usagePct > 70 ? 'warning' : '';
|
||||
|
||||
html += `<tr onclick="Customers.showDetail(${c.id})">
|
||||
<td><strong>${c.name}</strong></td>
|
||||
<td>${c.rfc || '-'}</td>
|
||||
<td>${c.phone || '-'}</td>
|
||||
<td><span class="tier-badge ${tierClass}">${tierName}</span></td>
|
||||
<td>${fmt(limit)}
|
||||
${limit > 0 ? `<div class="credit-bar"><div class="credit-fill ${fillClass}" style="width:${usagePct}%"></div></div>` : ''}
|
||||
</td>
|
||||
<td>${balance > 0 ? fmt(balance) : '-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
if (customers.length === 0) {
|
||||
html = '<tr><td colspan="6" style="text-align:center;color:#999;padding:20px;">Sin resultados</td></tr>';
|
||||
}
|
||||
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPagination(pag) {
|
||||
const container = document.getElementById('pagination');
|
||||
if (pag.total_pages <= 1) { container.innerHTML = ''; return; }
|
||||
|
||||
let html = '';
|
||||
if (pag.page > 1) {
|
||||
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page - 1})">Anterior</button>`;
|
||||
}
|
||||
for (let i = 1; i <= Math.min(pag.total_pages, 10); i++) {
|
||||
html += `<button class="btn ${i === pag.page ? 'active' : 'btn-secondary'}" onclick="Customers.goToPage(${i})">${i}</button>`;
|
||||
}
|
||||
if (pag.page < pag.total_pages) {
|
||||
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page + 1})">Siguiente</button>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
currentPage = page;
|
||||
loadCustomers(page);
|
||||
}
|
||||
|
||||
function search() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadCustomers(1);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ─── Detail ──────────────────────────
|
||||
async function showDetail(id) {
|
||||
try {
|
||||
const c = await api(`/pos/api/customers/${id}`);
|
||||
currentCustomer = c;
|
||||
|
||||
document.getElementById('detailName').textContent = c.name;
|
||||
|
||||
// Credit
|
||||
const available = (c.credit_limit || 0) - (c.credit_balance || 0);
|
||||
document.getElementById('detailCreditAvailable').textContent = fmt(available);
|
||||
document.getElementById('detailCreditLimit').textContent = fmt(c.credit_limit);
|
||||
document.getElementById('detailCreditBalance').textContent = fmt(c.credit_balance);
|
||||
|
||||
// Fiscal
|
||||
let fiscalHtml = '';
|
||||
fiscalHtml += `<div class="detail-field"><span class="label">RFC</span><span>${c.rfc || '-'}</span></div>`;
|
||||
fiscalHtml += `<div class="detail-field"><span class="label">Razon Social</span><span>${c.razon_social || '-'}</span></div>`;
|
||||
fiscalHtml += `<div class="detail-field"><span class="label">Regimen</span><span>${c.regimen_fiscal || '-'}</span></div>`;
|
||||
fiscalHtml += `<div class="detail-field"><span class="label">Uso CFDI</span><span>${c.uso_cfdi || '-'}</span></div>`;
|
||||
fiscalHtml += `<div class="detail-field"><span class="label">CP</span><span>${c.cp || '-'}</span></div>`;
|
||||
document.getElementById('detailFiscal').innerHTML = fiscalHtml;
|
||||
|
||||
// Contact
|
||||
let contactHtml = '';
|
||||
contactHtml += `<div class="detail-field"><span class="label">Telefono</span><span>${c.phone || '-'}</span></div>`;
|
||||
contactHtml += `<div class="detail-field"><span class="label">Email</span><span>${c.email || '-'}</span></div>`;
|
||||
contactHtml += `<div class="detail-field"><span class="label">Direccion</span><span>${c.address || '-'}</span></div>`;
|
||||
document.getElementById('detailContact').innerHTML = contactHtml;
|
||||
|
||||
// Vehicles
|
||||
const vehicles = c.vehicle_info || [];
|
||||
if (vehicles.length === 0) {
|
||||
document.getElementById('detailVehicles').innerHTML = '<div style="color:#999;font-size:13px;">Sin vehiculos registrados</div>';
|
||||
} else {
|
||||
document.getElementById('detailVehicles').innerHTML = vehicles.map(v =>
|
||||
`<div class="vehicle-card">
|
||||
<strong>${v.make || ''} ${v.model || ''} ${v.year || ''}</strong>
|
||||
${v.plates ? `<span style="margin-left:8px;color:#666;">Placas: ${v.plates}</span>` : ''}
|
||||
${v.vin ? `<div style="font-size:11px;color:#999;">VIN: ${v.vin}</div>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Recent purchases
|
||||
const purchases = c.recent_purchases || [];
|
||||
if (purchases.length === 0) {
|
||||
document.getElementById('detailPurchases').innerHTML = '<div style="color:#999;font-size:13px;">Sin compras recientes</div>';
|
||||
} else {
|
||||
document.getElementById('detailPurchases').innerHTML = purchases.map(p =>
|
||||
`<div class="purchase-item">
|
||||
<span>Venta #${p.id} - ${new Date(p.created_at).toLocaleDateString('es-MX')}</span>
|
||||
<span>${fmt(p.total)}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
document.getElementById('detailPanel').classList.add('active');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
document.getElementById('detailPanel').classList.remove('active');
|
||||
currentCustomer = null;
|
||||
}
|
||||
|
||||
// ─── Create/Edit Modal ───────────────
|
||||
function showCreateModal() {
|
||||
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';
|
||||
document.getElementById('customerModal').classList.add('active');
|
||||
document.getElementById('fName').focus();
|
||||
}
|
||||
|
||||
function editCurrent() {
|
||||
if (!currentCustomer) return;
|
||||
const c = currentCustomer;
|
||||
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;
|
||||
document.getElementById('customerModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('customerModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const name = document.getElementById('fName').value.trim();
|
||||
if (!name) { alert('Nombre es requerido'); return; }
|
||||
|
||||
const body = {
|
||||
name: name,
|
||||
rfc: document.getElementById('fRfc').value.trim() || null,
|
||||
razon_social: document.getElementById('fRazonSocial').value.trim() || null,
|
||||
regimen_fiscal: document.getElementById('fRegimenFiscal').value || null,
|
||||
uso_cfdi: document.getElementById('fUsoCfdi').value || 'G03',
|
||||
cp: document.getElementById('fCp').value.trim() || null,
|
||||
phone: document.getElementById('fPhone').value.trim() || null,
|
||||
email: document.getElementById('fEmail').value.trim() || null,
|
||||
address: document.getElementById('fAddress').value.trim() || null,
|
||||
price_tier: parseInt(document.getElementById('fPriceTier').value) || 1,
|
||||
credit_limit: parseFloat(document.getElementById('fCreditLimit').value) || 0,
|
||||
};
|
||||
|
||||
const editId = document.getElementById('editId').value;
|
||||
|
||||
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) {
|
||||
showDetail(editId);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Statement ───────────────────────
|
||||
async function showStatement() {
|
||||
if (!currentCustomer) return;
|
||||
document.getElementById('statementName').textContent = currentCustomer.name;
|
||||
|
||||
try {
|
||||
const data = await api(`/pos/api/customers/${currentCustomer.id}/statement`);
|
||||
|
||||
let html = `<div style="margin-bottom:12px;font-size:14px;">
|
||||
<strong>Saldo actual: ${fmt(data.balance)}</strong> |
|
||||
Limite: ${fmt(data.customer.credit_limit)}
|
||||
</div>`;
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
html += '<div style="color:#999;padding:20px;text-align:center;">Sin movimientos</div>';
|
||||
} else {
|
||||
html += '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
|
||||
html += '<tr style="background:#f5f5f5;"><th style="padding:8px;text-align:left;">Fecha</th><th style="text-align:left;">Concepto</th><th style="text-align:right;padding:8px;">Cargo</th><th style="text-align:right;padding:8px;">Abono</th><th style="text-align:right;padding:8px;">Saldo</th></tr>';
|
||||
|
||||
data.entries.forEach(e => {
|
||||
const dateStr = new Date(e.date).toLocaleDateString('es-MX');
|
||||
html += `<tr style="border-bottom:1px solid #eee;">
|
||||
<td style="padding:6px 8px;">${dateStr}</td>
|
||||
<td>${e.description}</td>
|
||||
<td style="text-align:right;padding:6px 8px;">${e.type === 'charge' ? fmt(e.amount) : ''}</td>
|
||||
<td style="text-align:right;padding:6px 8px;">${e.type === 'payment' ? fmt(e.amount) : ''}</td>
|
||||
<td style="text-align:right;padding:6px 8px;">${fmt(e.running_balance)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
document.getElementById('statementContent').innerHTML = html;
|
||||
document.getElementById('statementModal').classList.add('active');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────
|
||||
loadCustomers();
|
||||
|
||||
return {
|
||||
search, goToPage, loadCustomers,
|
||||
showDetail, closeDetail,
|
||||
showCreateModal, editCurrent, closeModal, save,
|
||||
showStatement,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user