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:
2026-03-31 03:36:42 +00:00
parent c66fb13c15
commit 76f738652b
2 changed files with 528 additions and 0 deletions

307
pos/static/js/customers.js Normal file
View 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,
};
})();

View File

@@ -0,0 +1,221 @@
<!-- /home/Autopartes/pos/templates/customers.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clientes - Nexus POS</title>
<link rel="stylesheet" href="/pos/static/css/common.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font-body, 'Segoe UI', system-ui, sans-serif); background: var(--color-bg, #f0f2f5); color: var(--color-text, #1a1a2e); }
.topbar { background: var(--color-primary, #1a1a2e); color: #fff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
.topbar h1 { font-size: 18px; font-weight: 600; }
.topbar .nav-links a { color: #b0bec5; text-decoration: none; margin-left: 16px; font-size: 14px; }
.topbar .nav-links a:hover { color: #fff; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
.toolbar input { flex: 1; padding: 10px 14px; border: 1px solid var(--color-border, #ddd); border-radius: var(--radius, 6px); font-size: 14px; }
.toolbar .btn { padding: 10px 20px; border: none; border-radius: var(--radius, 6px); cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-primary { background: var(--color-primary, #1a1a2e); color: #fff; }
.btn-secondary { background: #e0e0e0; color: #333; }
.customers-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius, 6px); overflow: hidden; box-shadow: var(--shadow, 0 1px 3px rgba(0,0,0,0.1)); }
.customers-table th { background: var(--color-surface, #f8f9fa); padding: 10px 12px; text-align: left; font-size: 12px; font-weight: 600; color: #666; border-bottom: 2px solid var(--color-border, #ddd); }
.customers-table td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
.customers-table tr { cursor: pointer; }
.customers-table tr:hover { background: #f8f9fa; }
.tier-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.tier-1 { background: #e3f2fd; color: #1565c0; }
.tier-2 { background: #e8f5e9; color: #2e7d32; }
.tier-3 { background: #fff3e0; color: #e65100; }
.credit-bar { width: 80px; height: 6px; background: #e0e0e0; border-radius: 3px; display: inline-block; vertical-align: middle; margin-left: 6px; }
.credit-fill { height: 100%; border-radius: 3px; background: #4caf50; }
.credit-fill.warning { background: #ff9800; }
.credit-fill.danger { background: #f44336; }
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 16px; }
.pagination .btn { padding: 6px 12px; font-size: 13px; }
.pagination .btn.active { background: var(--color-primary, #1a1a2e); color: #fff; }
/* Detail panel */
.detail-panel { display: none; position: fixed; top: 0; right: 0; width: 480px; height: 100vh; background: #fff; box-shadow: -4px 0 20px rgba(0,0,0,0.15); z-index: 100; overflow-y: auto; }
.detail-panel.active { display: block; }
.detail-header { padding: 16px 20px; background: var(--color-primary, #1a1a2e); color: #fff; display: flex; justify-content: space-between; align-items: center; }
.detail-header h2 { font-size: 16px; }
.detail-header .btn-close { background: none; border: none; color: #fff; font-size: 24px; cursor: pointer; }
.detail-body { padding: 20px; }
.detail-section { margin-bottom: 20px; }
.detail-section h3 { font-size: 14px; font-weight: 600; color: #666; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px; }
.detail-field { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.detail-field .label { color: #999; }
.credit-summary { background: #f5f5f5; padding: 12px; border-radius: 8px; margin-bottom: 16px; }
.credit-summary .big-number { font-size: 24px; font-weight: 700; }
.purchases-list { max-height: 300px; overflow-y: auto; }
.purchase-item { padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; display: flex; justify-content: space-between; }
.vehicle-card { background: #f5f5f5; padding: 10px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; }
/* Modal */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 200; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: #fff; border-radius: 12px; padding: 24px; width: 550px; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
.modal h2 { margin-bottom: 16px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-grid .full-width { grid-column: 1 / -1; }
.form-field { display: flex; flex-direction: column; gap: 4px; }
.form-field label { font-size: 12px; color: #666; font-weight: 500; }
.form-field input, .form-field select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
.modal-actions .btn { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
</style>
</head>
<body>
<div class="topbar">
<h1>Clientes</h1>
<div class="nav-links">
<a href="/pos/sale">POS</a>
<a href="/pos/catalog">Catalogo</a>
<a href="/pos/inventory">Inventario</a>
<a href="/pos/customers">Clientes</a>
</div>
</div>
<div class="container">
<div class="toolbar">
<input type="text" id="searchInput" placeholder="Buscar por nombre, RFC, telefono..." oninput="Customers.search()">
<button class="btn btn-primary" onclick="Customers.showCreateModal()">+ Nuevo Cliente</button>
</div>
<table class="customers-table">
<thead>
<tr>
<th>Nombre</th>
<th>RFC</th>
<th>Telefono</th>
<th>Lista</th>
<th>Credito</th>
<th>Saldo</th>
</tr>
</thead>
<tbody id="customersBody"></tbody>
</table>
<div class="pagination" id="pagination"></div>
</div>
<!-- Detail Panel (slides in from right) -->
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<h2 id="detailName">Cliente</h2>
<button class="btn-close" onclick="Customers.closeDetail()">&times;</button>
</div>
<div class="detail-body">
<div class="detail-section">
<div class="credit-summary">
<div>Credito disponible</div>
<div class="big-number" id="detailCreditAvailable">$0.00</div>
<div style="font-size: 12px; color: #666;">
Limite: <span id="detailCreditLimit">$0.00</span> |
Saldo: <span id="detailCreditBalance">$0.00</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Datos Fiscales</h3>
<div id="detailFiscal"></div>
</div>
<div class="detail-section">
<h3>Contacto</h3>
<div id="detailContact"></div>
</div>
<div class="detail-section">
<h3>Vehiculos</h3>
<div id="detailVehicles"></div>
</div>
<div class="detail-section">
<h3>Compras Recientes</h3>
<div class="purchases-list" id="detailPurchases"></div>
</div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button class="btn btn-primary" onclick="Customers.editCurrent()">Editar</button>
<button class="btn btn-secondary" onclick="Customers.showStatement()">Estado de Cuenta</button>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal-overlay" id="customerModal">
<div class="modal">
<h2 id="modalTitle">Nuevo Cliente</h2>
<input type="hidden" id="editId">
<div class="form-grid">
<div class="form-field full-width"><label>Nombre *</label><input type="text" id="fName"></div>
<div class="form-field"><label>RFC</label><input type="text" id="fRfc" maxlength="13"></div>
<div class="form-field"><label>Razon Social</label><input type="text" id="fRazonSocial"></div>
<div class="form-field"><label>Regimen Fiscal</label>
<select id="fRegimenFiscal">
<option value="">Seleccionar...</option>
<option value="601">601 - General de Ley PM</option>
<option value="603">603 - PM Fines No Lucrativos</option>
<option value="605">605 - Sueldos y Salarios</option>
<option value="606">606 - Arrendamiento</option>
<option value="612">612 - PF Actividad Empresarial</option>
<option value="616">616 - Sin Obligaciones Fiscales</option>
<option value="621">621 - Incorporacion Fiscal</option>
<option value="625">625 - RESICO</option>
</select>
</div>
<div class="form-field"><label>Uso CFDI</label>
<select id="fUsoCfdi">
<option value="G03">G03 - Gastos en general</option>
<option value="G01">G01 - Adquisicion de mercancias</option>
<option value="P01">P01 - Por definir</option>
</select>
</div>
<div class="form-field"><label>Codigo Postal</label><input type="text" id="fCp" maxlength="5"></div>
<div class="form-field"><label>Telefono</label><input type="tel" id="fPhone"></div>
<div class="form-field"><label>Email</label><input type="email" id="fEmail"></div>
<div class="form-field full-width"><label>Direccion</label><input type="text" id="fAddress"></div>
<div class="form-field"><label>Lista de precio</label>
<select id="fPriceTier">
<option value="1">1 - Mostrador</option>
<option value="2">2 - Taller</option>
<option value="3">3 - Mayoreo</option>
</select>
</div>
<div class="form-field"><label>Limite de credito</label><input type="number" id="fCreditLimit" value="0" min="0" step="100"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="Customers.closeModal()">Cancelar</button>
<button class="btn btn-primary" onclick="Customers.save()">Guardar</button>
</div>
</div>
</div>
<!-- Statement Modal -->
<div class="modal-overlay" id="statementModal">
<div class="modal" style="width: 650px;">
<h2>Estado de Cuenta: <span id="statementName"></span></h2>
<div id="statementContent" style="max-height: 500px; overflow-y: auto;"></div>
<div class="modal-actions" style="margin-top: 16px;">
<button class="btn btn-secondary" onclick="document.getElementById('statementModal').classList.remove('active')">Cerrar</button>
</div>
</div>
</div>
<script src="/pos/static/js/customers.js"></script>
</body>
</html>