fix(pos): rewrite customers.js to match design system HTML structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 08:35:19 +00:00
parent 004cca7ccc
commit 56fed52253
2 changed files with 586 additions and 533 deletions

View File

@@ -2,10 +2,12 @@
/**
* 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;
@@ -13,6 +15,13 @@ const Customers = (() => {
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 };
}
@@ -20,23 +29,66 @@ const Customers = (() => {
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 '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>';
}
return '<span class="badge badge--active"><span class="badge-dot"></span>Activo</span>';
}
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;
q = q !== undefined ? q : (document.getElementById('searchInput').value || '');
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);
renderTable(data.data || []);
renderPagination(data.pagination || {});
} catch (e) {
console.error('Load customers failed:', e);
}
@@ -44,53 +96,90 @@ const Customers = (() => {
function renderTable(customers) {
const tbody = document.getElementById('customersBody');
const tiers = { 1: ['Mostrador', 'tier-1'], 2: ['Taller', 'tier-2'], 3: ['Mayoreo', 'tier-3'] };
if (!tbody) return;
tbody.innerHTML = '';
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>';
if (!customers || customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>';
return;
}
tbody.innerHTML = html;
customers.forEach((c, idx) => {
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 tr = document.createElement('tr');
if (currentCustomer && currentCustomer.id === c.id) tr.className = 'selected';
tr.onclick = () => selectCustomer(c.id);
tr.innerHTML = `
<td class="cell-num">${num}</td>
<td>
<div class="cell-name">${c.name || ''}</div>
<div class="cell-name-sub hide-mobile">${c.email || ''}</div>
</td>
<td class="cell-rfc hide-mobile">${c.rfc || '-'}</td>
<td class="hide-mobile">${c.phone || '-'}</td>
<td class="hide-mobile" style="font-size:var(--text-caption);color:var(--color-text-secondary);">${c.email || '-'}</td>
<td><span class="tipo-chip tipo-chip--${tClass}">${tier}</span></td>
<td class="cell-credit ${creditClass}">${fmt(available)}</td>
<td class="cell-date hide-mobile">${formatDate(c.last_purchase || c.created_at)}</td>
<td>${statusBadge(c)}</td>
`;
tbody.appendChild(tr);
});
}
function renderPagination(pag) {
const container = document.getElementById('pagination');
if (pag.total_pages <= 1) { container.innerHTML = ''; return; }
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 = '';
if (pag.page > 1) {
html += `<button class="btn btn-secondary" onclick="Customers.goToPage(${pag.page - 1})">Anterior</button>`;
html += `<button class="page-btn" ${pag.page <= 1 ? 'disabled' : ''} onclick="Customers.goToPage(${pag.page - 1})">&#8249;</button>`;
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 += `<button class="page-btn ${i === pag.page ? 'active' : ''}" onclick="Customers.goToPage(${i})">${i}</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>`;
if (endP < totalPages) {
html += '<span style="color:var(--color-text-muted);font-size:var(--text-caption);padding:0 4px;">...</span>';
html += `<button class="page-btn" onclick="Customers.goToPage(${totalPages})">${totalPages}</button>`;
}
html += `<button class="page-btn" ${pag.page >= totalPages ? 'disabled' : ''} onclick="Customers.goToPage(${pag.page + 1})">&#8250;</button>`;
container.innerHTML = html;
}
function goToPage(page) {
if (page < 1 || page > totalPages) return;
currentPage = page;
loadCustomers(page);
}
@@ -103,76 +192,182 @@ const Customers = (() => {
}, 300);
}
// ─── Detail ──────────────────────────
async function showDetail(id) {
// 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;
document.getElementById('detailName').textContent = c.name;
// 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';
// 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);
// Avatar & Header
const avatarEl = document.getElementById('detailAvatar');
if (avatarEl) avatarEl.textContent = getInitials(c.name);
// 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;
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
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;
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));
// Vehicles
// 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 = '<tr><td colspan="4" style="text-align:center;color:var(--color-text-muted);padding:var(--space-4);">Sin compras recientes</td></tr>';
} 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 = `
<td class="date">${formatDate(p.created_at)}</td>
<td class="folio">NX-${String(p.id).padStart(5, '0')}</td>
<td class="total">${fmt(p.total)}</td>
<td><span class="mbadge ${statusClass}">${statusLabel}</span></td>
`;
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) {
document.getElementById('detailVehicles').innerHTML = '<div style="color:#999;font-size:13px;">Sin vehiculos registrados</div>';
vehiclesEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin vehiculos registrados</span>';
} else {
document.getElementById('detailVehicles').innerHTML = vehicles.map(v =>
`<div class="vehicle-card">
vehiclesEl.innerHTML = vehicles.map(v =>
`<div style="padding:4px 0;border-bottom:1px solid var(--color-border);">
<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>` : ''}
${v.plates ? ` <span style="color:var(--color-text-muted);">Placas: ${v.plates}</span>` : ''}
</div>`
).join('');
}
}
// Recent purchases
// Purchases
const purchasesEl = document.getElementById('panelPurchases');
if (purchasesEl) {
const purchases = c.recent_purchases || [];
if (purchases.length === 0) {
document.getElementById('detailPurchases').innerHTML = '<div style="color:#999;font-size:13px;">Sin compras recientes</div>';
purchasesEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin compras recientes</span>';
} 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>
purchasesEl.innerHTML = purchases.slice(0, 5).map(p =>
`<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--color-border);font-size:var(--text-body-sm);">
<span>NX-${String(p.id).padStart(5, '0')} ${formatDate(p.created_at)}</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;
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 = '';
@@ -186,13 +381,15 @@ const Customers = (() => {
document.getElementById('fAddress').value = '';
document.getElementById('fPriceTier').value = '1';
document.getElementById('fCreditLimit').value = '0';
document.getElementById('customerModal').classList.add('active');
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 || '';
@@ -206,32 +403,36 @@ const Customers = (() => {
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');
modal.classList.add('active');
}
function closeModal() {
document.getElementById('customerModal').classList.remove('active');
const modal = document.getElementById('customerModal');
if (modal) modal.classList.remove('active');
}
async function save() {
const name = document.getElementById('fName').value.trim();
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: 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,
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 = document.getElementById('editId').value;
const editId = val('editId');
try {
if (editId) {
@@ -249,37 +450,43 @@ const Customers = (() => {
closeModal();
loadCustomers();
if (editId && currentCustomer) {
showDetail(editId);
selectCustomer(editId);
}
} catch (e) {
alert('Error: ' + e.message);
}
}
// ─── Statement ───────────────────────
// ─── Statement Modal ─────────────────
async function showStatement() {
if (!currentCustomer) return;
document.getElementById('statementName').textContent = currentCustomer.name;
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 = '<div style="text-align:center;padding:20px;color:var(--color-text-muted);">Cargando...</div>';
modal.classList.add('active');
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)}
Limite: ${fmt(data.customer ? data.customer.credit_limit : 0)}
</div>`;
if (data.entries.length === 0) {
html += '<div style="color:#999;padding:20px;text-align:center;">Sin movimientos</div>';
if (!data.entries || data.entries.length === 0) {
html += '<div style="color:var(--color-text-muted);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>';
html += '<tr style="background:var(--color-surface-2);"><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>
html += `<tr style="border-bottom:1px solid var(--color-border);">
<td style="padding:6px 8px;">${formatDate(e.date)}</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>
@@ -288,20 +495,280 @@ const Customers = (() => {
html += '</table>';
}
document.getElementById('statementContent').innerHTML = html;
document.getElementById('statementModal').classList.add('active');
if (content) content.innerHTML = html;
} catch (e) {
if (content) content.innerHTML = `<div style="color:var(--color-error);padding:20px;text-align:center;">Error: ${e.message}</div>`;
}
}
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 ────────────────────────────
loadCustomers();
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 = `
<div id="customerModal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<div class="modal-header">
<h3 id="modalTitle">Nuevo Cliente</h3>
<button class="btn-close" onclick="Customers.closeModal()">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="editId" />
<div class="form-row">
<div class="form-group"><label>Nombre *</label><input type="text" id="fName" class="form-input" placeholder="Nombre del cliente" /></div>
<div class="form-group"><label>RFC</label><input type="text" id="fRfc" class="form-input" placeholder="RFC" maxlength="13" /></div>
</div>
<div class="form-row">
<div class="form-group"><label>Razon Social</label><input type="text" id="fRazonSocial" class="form-input" placeholder="Razon Social" /></div>
<div class="form-group"><label>Regimen Fiscal</label>
<select id="fRegimenFiscal" class="form-input">
<option value="">-- Seleccionar --</option>
<option value="601">601 - General de Ley PM</option>
<option value="603">603 - Personas Morales Fines No Lucrativos</option>
<option value="605">605 - Sueldos y Salarios</option>
<option value="606">606 - Arrendamiento</option>
<option value="608">608 - Demas Ingresos</option>
<option value="612">612 - Personas Fisicas Empresariales</option>
<option value="616">616 - Sin Obligaciones Fiscales</option>
<option value="621">621 - Incorporacion Fiscal</option>
<option value="625">625 - RESICO</option>
<option value="626">626 - RESICO PM</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group"><label>Uso CFDI</label>
<select id="fUsoCfdi" class="form-input">
<option value="G01">G01 - Adquisicion de mercancias</option>
<option value="G03" selected>G03 - Gastos en general</option>
<option value="I01">I01 - Construcciones</option>
<option value="I08">I08 - Otra maquinaria</option>
<option value="P01">P01 - Por definir</option>
<option value="S01">S01 - Sin efectos fiscales</option>
</select>
</div>
<div class="form-group"><label>Codigo Postal</label><input type="text" id="fCp" class="form-input" placeholder="C.P." maxlength="5" /></div>
</div>
<div class="form-row">
<div class="form-group"><label>Telefono</label><input type="text" id="fPhone" class="form-input" placeholder="Telefono" /></div>
<div class="form-group"><label>Email</label><input type="email" id="fEmail" class="form-input" placeholder="correo@ejemplo.com" /></div>
</div>
<div class="form-group"><label>Direccion</label><input type="text" id="fAddress" class="form-input" placeholder="Direccion" /></div>
<div class="form-row">
<div class="form-group"><label>Tipo Precio</label>
<select id="fPriceTier" class="form-input">
<option value="1">Mostrador</option>
<option value="2">Taller</option>
<option value="3">Mayoreo</option>
</select>
</div>
<div class="form-group"><label>Limite de Credito</label><input type="number" id="fCreditLimit" class="form-input" value="0" min="0" step="1000" /></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="Customers.closeModal()">Cancelar</button>
<button class="btn btn-primary" onclick="Customers.save()">Guardar</button>
</div>
</div>
</div>`;
document.body.appendChild(div);
}
// Statement Modal
if (!document.getElementById('statementModal')) {
const div = document.createElement('div');
div.innerHTML = `
<div id="statementModal" class="modal-overlay" style="display:none;">
<div class="modal-box modal-box--wide">
<div class="modal-header">
<h3>Estado de Cuenta — <span id="statementName"></span></h3>
<button class="btn-close" onclick="Customers.closeStatement()">&times;</button>
</div>
<div class="modal-body" id="statementContent" style="max-height:60vh;overflow-y:auto;">
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="Customers.showPaymentModal()">Registrar Abono</button>
<button class="btn btn-secondary" onclick="Customers.closeStatement()">Cerrar</button>
</div>
</div>
</div>`;
document.body.appendChild(div);
}
// Payment Modal
if (!document.getElementById('paymentModal')) {
const div = document.createElement('div');
div.innerHTML = `
<div id="paymentModal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<div class="modal-header">
<h3>Registrar Abono</h3>
<button class="btn-close" onclick="Customers.closePayment()">&times;</button>
</div>
<div class="modal-body">
<p style="margin-bottom:var(--space-4);color:var(--color-text-secondary);">Cliente: <strong id="paymentCustomerName"></strong></p>
<div class="form-row">
<div class="form-group"><label>Monto</label><input type="number" id="paymentAmount" class="form-input" placeholder="0.00" min="0" step="0.01" /></div>
<div class="form-group"><label>Metodo</label>
<select id="paymentMethod" class="form-input">
<option value="cash">Efectivo</option>
<option value="transfer">Transferencia</option>
<option value="card">Tarjeta</option>
<option value="check">Cheque</option>
</select>
</div>
</div>
<div class="form-group"><label>Referencia</label><input type="text" id="paymentRef" class="form-input" placeholder="Num. referencia (opcional)" /></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="Customers.closePayment()">Cancelar</button>
<button class="btn btn-primary" onclick="Customers.recordPayment()">Registrar</button>
</div>
</div>
</div>`;
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, closeDetail,
showDetail, selectCustomer, closeDetail,
showCreateModal, editCurrent, closeModal, save,
showStatement,
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
};
})();

View File

@@ -2044,428 +2044,7 @@
}
/* ------------------------------------------------------------------
SAMPLE DATA
------------------------------------------------------------------ */
const customers = [
{
id: 1, num: '00001',
name: 'Taller Star Automotriz', initials: 'TS',
rfc: 'TSA820115HDF', phone: '55 1234-5678', email: 'ventas@tallerstar.mx',
tipo: 'Taller', credit: 48500, creditLimit: 80000,
lastPurchase: '28 Mar 2026', estado: 'Activo',
address: 'Av. Insurgentes Sur 1602, Col. Crédito Constructor, CDMX',
since: '14 Mar 2019',
history: [
{ date: '28 Mar 2026', folio: 'NX-20498', total: '$4,820.00', status: 'Pagado' },
{ date: '22 Mar 2026', folio: 'NX-20341', total: '$1,250.50', status: 'Pagado' },
{ date: '15 Mar 2026', folio: 'NX-20188', total: '$9,670.00', status: 'Pendiente' },
{ date: '07 Mar 2026', folio: 'NX-19982', total: '$3,100.00', status: 'Pagado' },
{ date: '28 Feb 2026', folio: 'NX-19740', total: '$6,450.00', status: 'Pagado' },
{ date: '14 Feb 2026', folio: 'NX-19510', total: '$2,890.00', status: 'Vencido' },
{ date: '05 Feb 2026', folio: 'NX-19301', total: '$5,220.00', status: 'Pagado' },
{ date: '25 Ene 2026', folio: 'NX-19050', total: '$780.00', status: 'Pagado' },
{ date: '18 Ene 2026', folio: 'NX-18890', total: '$11,340.00', status: 'Pagado' },
{ date: '10 Ene 2026', folio: 'NX-18720', total: '$4,100.00', status: 'Pagado' },
]
},
{
id: 2, num: '00002',
name: 'Refacciones El Trueno', initials: 'RT',
rfc: 'RTE930720HDF', phone: '55 9876-5432', email: 'compras@eltrueno.com',
tipo: 'Mayoreo', credit: 120000, creditLimit: 200000,
lastPurchase: '27 Mar 2026', estado: 'Activo',
address: 'Blvd. Adolfo López Mateos 2855, Álvaro Obregón, CDMX',
since: '02 Jun 2017',
history: [
{ date: '27 Mar 2026', folio: 'NX-20481', total: '$28,500.00', status: 'Pendiente' },
{ date: '20 Mar 2026', folio: 'NX-20290', total: '$15,670.00', status: 'Pagado' },
{ date: '12 Mar 2026', folio: 'NX-20110', total: '$42,300.00', status: 'Pagado' },
{ date: '04 Mar 2026', folio: 'NX-19930', total: '$8,900.00', status: 'Pagado' },
{ date: '25 Feb 2026', folio: 'NX-19720', total: '$33,100.00', status: 'Pagado' },
{ date: '16 Feb 2026', folio: 'NX-19480', total: '$21,450.00', status: 'Pagado' },
{ date: '07 Feb 2026', folio: 'NX-19240', total: '$9,800.00', status: 'Pagado' },
{ date: '28 Ene 2026', folio: 'NX-19010', total: '$47,200.00', status: 'Pagado' },
{ date: '19 Ene 2026', folio: 'NX-18840', total: '$18,660.00', status: 'Pagado' },
{ date: '10 Ene 2026', folio: 'NX-18650', total: '$12,400.00', status: 'Pagado' },
]
},
{
id: 3, num: '00003',
name: 'Karla Mendoza Jiménez', initials: 'KM',
rfc: 'MEJK891004MDF', phone: '55 5551-2233', email: 'karla.m@gmail.com',
tipo: 'Mostrador', credit: 0, creditLimit: 5000,
lastPurchase: '25 Mar 2026', estado: 'Activo',
address: 'Calle Fresno 44, Col. San Marcos, Xochimilco, CDMX',
since: '10 Ene 2023',
history: [
{ date: '25 Mar 2026', folio: 'NX-20445', total: '$340.00', status: 'Pagado' },
{ date: '10 Mar 2026', folio: 'NX-20080', total: '$780.00', status: 'Pagado' },
{ date: '18 Feb 2026', folio: 'NX-19560', total: '$210.00', status: 'Pagado' },
{ date: '05 Feb 2026', folio: 'NX-19290', total: '$1,200.00',status: 'Pagado' },
{ date: '20 Ene 2026', folio: 'NX-18920', total: '$450.00', status: 'Pagado' },
{ date: '03 Ene 2026', folio: 'NX-18600', total: '$890.00', status: 'Pagado' },
{ date: '15 Dic 2025', folio: 'NX-18100', total: '$620.00', status: 'Pagado' },
{ date: '01 Dic 2025', folio: 'NX-17890', total: '$330.00', status: 'Pagado' },
{ date: '18 Nov 2025', folio: 'NX-17620', total: '$1,100.00',status: 'Pagado' },
{ date: '05 Nov 2025', folio: 'NX-17390', total: '$780.00', status: 'Pagado' },
]
},
{
id: 4, num: '00004',
name: 'Distribuidora Central Norte', initials: 'DC',
rfc: 'DCN760315B24', phone: '55 8888-0011', email: 'pedidos@cnorte.mx',
tipo: 'Mayoreo', credit: 65000, creditLimit: 300000,
lastPurchase: '26 Mar 2026', estado: 'Mora',
address: 'Calz. de los Misterios 1420, Gustavo A. Madero, CDMX',
since: '30 Ago 2014',
history: [
{ date: '26 Mar 2026', folio: 'NX-20470', total: '$55,200.00', status: 'Pendiente' },
{ date: '15 Mar 2026', folio: 'NX-20140', total: '$38,900.00', status: 'Vencido' },
{ date: '05 Mar 2026', folio: 'NX-19960', total: '$22,100.00', status: 'Vencido' },
{ date: '18 Feb 2026', folio: 'NX-19500', total: '$41,700.00', status: 'Pagado' },
{ date: '06 Feb 2026', folio: 'NX-19270', total: '$29,800.00', status: 'Pagado' },
{ date: '25 Ene 2026', folio: 'NX-19040', total: '$63,400.00', status: 'Pagado' },
{ date: '14 Ene 2026', folio: 'NX-18810', total: '$17,600.00', status: 'Pagado' },
{ date: '03 Ene 2026', folio: 'NX-18580', total: '$34,900.00', status: 'Pagado' },
{ date: '22 Dic 2025', folio: 'NX-18200', total: '$48,100.00', status: 'Pagado' },
{ date: '10 Dic 2025', folio: 'NX-17980', total: '$26,300.00', status: 'Pagado' },
]
},
{
id: 5, num: '00005',
name: 'Taller Rápido Orozco', initials: 'TR',
rfc: 'ORCH751122HMC', phone: '55 7744-3322', email: 'rapidorozco@outlook.com',
tipo: 'Taller', credit: 18000, creditLimit: 25000,
lastPurchase: '24 Mar 2026', estado: 'Activo',
address: 'Calle Morelos 88, Col. Centro, Naucalpan, Edo. Méx.',
since: '05 May 2021',
history: [
{ date: '24 Mar 2026', folio: 'NX-20430', total: '$3,450.00', status: 'Pagado' },
{ date: '17 Mar 2026', folio: 'NX-20210', total: '$1,880.00', status: 'Pagado' },
{ date: '09 Mar 2026', folio: 'NX-20040', total: '$6,100.00', status: 'Pendiente' },
{ date: '01 Mar 2026', folio: 'NX-19870', total: '$2,200.00', status: 'Pagado' },
{ date: '22 Feb 2026', folio: 'NX-19660', total: '$4,760.00', status: 'Pagado' },
{ date: '12 Feb 2026', folio: 'NX-19420', total: '$920.00', status: 'Pagado' },
{ date: '02 Feb 2026', folio: 'NX-19200', total: '$3,340.00', status: 'Pagado' },
{ date: '24 Ene 2026', folio: 'NX-18990', total: '$1,560.00', status: 'Pagado' },
{ date: '15 Ene 2026', folio: 'NX-18800', total: '$5,280.00', status: 'Pagado' },
{ date: '06 Ene 2026', folio: 'NX-18610', total: '$2,990.00', status: 'Pagado' },
]
},
{
id: 6, num: '00006',
name: 'Servicios Automotrices Luna', initials: 'SL',
rfc: 'SAL880903HDF', phone: '55 3311-7766', email: 'luna.serv@yahoo.com',
tipo: 'Taller', credit: 4500, creditLimit: 15000,
lastPurchase: '23 Mar 2026', estado: 'Activo',
address: 'Av. Taxqueña 1201, Coyoacán, CDMX',
since: '12 Sep 2020',
history: [
{ date: '23 Mar 2026', folio: 'NX-20410', total: '$2,100.00', status: 'Pagado' },
{ date: '14 Mar 2026', folio: 'NX-20170', total: '$880.00', status: 'Pagado' },
{ date: '06 Mar 2026', folio: 'NX-19990', total: '$3,650.00', status: 'Pagado' },
{ date: '25 Feb 2026', folio: 'NX-19730', total: '$1,220.00', status: 'Pagado' },
{ date: '15 Feb 2026', folio: 'NX-19490', total: '$4,400.00', status: 'Pagado' },
{ date: '05 Feb 2026', folio: 'NX-19260', total: '$760.00', status: 'Pagado' },
{ date: '26 Ene 2026', folio: 'NX-19030', total: '$2,980.00', status: 'Pagado' },
{ date: '16 Ene 2026', folio: 'NX-18820', total: '$1,450.00', status: 'Pagado' },
{ date: '07 Ene 2026', folio: 'NX-18630', total: '$3,100.00', status: 'Pagado' },
{ date: '28 Dic 2025', folio: 'NX-18240', total: '$590.00', status: 'Pagado' },
]
},
{
id: 7, num: '00007',
name: 'Autoservicio El Piston', initials: 'AP',
rfc: 'AEP920601HDF', phone: '55 2299-1144', email: 'elpiston@hotmail.com',
tipo: 'Mostrador', credit: 3200, creditLimit: 10000,
lastPurchase: '21 Mar 2026', estado: 'Inactivo',
address: 'Calle Hidalgo 557, Tlalnepantla, Edo. Méx.',
since: '20 Feb 2022',
history: [
{ date: '21 Mar 2026', folio: 'NX-20380', total: '$1,800.00', status: 'Pagado' },
{ date: '08 Mar 2026', folio: 'NX-20020', total: '$940.00', status: 'Pagado' },
{ date: '18 Feb 2026', folio: 'NX-19520', total: '$2,600.00', status: 'Pagado' },
{ date: '05 Feb 2026', folio: 'NX-19290', total: '$780.00', status: 'Pagado' },
{ date: '23 Ene 2026', folio: 'NX-19010', total: '$3,400.00', status: 'Pagado' },
{ date: '12 Ene 2026', folio: 'NX-18790', total: '$560.00', status: 'Pagado' },
{ date: '02 Ene 2026', folio: 'NX-18560', total: '$1,230.00', status: 'Pagado' },
{ date: '20 Dic 2025', folio: 'NX-18160', total: '$2,100.00', status: 'Pagado' },
{ date: '09 Dic 2025', folio: 'NX-17960', total: '$870.00', status: 'Pagado' },
{ date: '28 Nov 2025', folio: 'NX-17740', total: '$1,540.00', status: 'Pagado' },
]
},
{
id: 8, num: '00008',
name: 'Mega Partes Industriales', initials: 'MP',
rfc: 'MPI840718B56', phone: '55 6600-9988', email: 'compras@megapartes.mx',
tipo: 'Mayoreo', credit: 200000, creditLimit: 500000,
lastPurchase: '29 Mar 2026', estado: 'Activo',
address: 'Av. 5 de Mayo 3800, Zona Industrial, Tlalnepantla, Edo. Méx.',
since: '08 Nov 2012',
history: [
{ date: '29 Mar 2026', folio: 'NX-20495', total: '$88,400.00', status: 'Pendiente' },
{ date: '23 Mar 2026', folio: 'NX-20415', total: '$62,100.00', status: 'Pagado' },
{ date: '16 Mar 2026', folio: 'NX-20230', total: '$105,800.00', status: 'Pagado' },
{ date: '08 Mar 2026', folio: 'NX-20050', total: '$47,300.00', status: 'Pagado' },
{ date: '28 Feb 2026', folio: 'NX-19750', total: '$93,600.00', status: 'Pagado' },
{ date: '19 Feb 2026', folio: 'NX-19540', total: '$38,700.00', status: 'Pagado' },
{ date: '09 Feb 2026', folio: 'NX-19310', total: '$71,200.00', status: 'Pagado' },
{ date: '30 Ene 2026', folio: 'NX-19080', total: '$56,900.00', status: 'Pagado' },
{ date: '21 Ene 2026', folio: 'NX-18870', total: '$43,500.00', status: 'Pagado' },
{ date: '12 Ene 2026', folio: 'NX-18680', total: '$82,100.00', status: 'Pagado' },
]
},
{
id: 9, num: '00009',
name: 'Taller Mecánico Pérez Hnos.', initials: 'TP',
rfc: 'PHM950228HDF', phone: '55 4433-6677', email: 'perezhermanos@gmail.com',
tipo: 'Taller', credit: 12000, creditLimit: 20000,
lastPurchase: '20 Mar 2026', estado: 'Activo',
address: 'Calle 5 No. 234, Col. Agrícola Oriental, Iztacalco, CDMX',
since: '14 Jul 2020',
history: [
{ date: '20 Mar 2026', folio: 'NX-20360', total: '$5,600.00', status: 'Pagado' },
{ date: '11 Mar 2026', folio: 'NX-20100', total: '$2,340.00', status: 'Pendiente' },
{ date: '02 Mar 2026', folio: 'NX-19900', total: '$7,800.00', status: 'Pagado' },
{ date: '21 Feb 2026', folio: 'NX-19640', total: '$1,450.00', status: 'Pagado' },
{ date: '11 Feb 2026', folio: 'NX-19400', total: '$3,900.00', status: 'Pagado' },
{ date: '01 Feb 2026', folio: 'NX-19180', total: '$6,200.00', status: 'Pagado' },
{ date: '23 Ene 2026', folio: 'NX-18970', total: '$2,890.00', status: 'Pagado' },
{ date: '14 Ene 2026', folio: 'NX-18780', total: '$4,100.00', status: 'Pagado' },
{ date: '05 Ene 2026', folio: 'NX-18590', total: '$1,780.00', status: 'Pagado' },
{ date: '27 Dic 2025', folio: 'NX-18210', total: '$8,400.00', status: 'Pagado' },
]
},
{
id: 10, num: '00010',
name: 'Import Autoparts Villanueva', initials: 'IV',
rfc: 'IAV010315B34', phone: '55 9900-1122', email: 'ventas@importav.mx',
tipo: 'Mayoreo', credit: 95000, creditLimit: 150000,
lastPurchase: '28 Mar 2026', estado: 'Activo',
address: 'Blvd. Manuel Ávila Camacho 600, Naucalpan, Edo. Méx.',
since: '22 Mar 2015',
history: [
{ date: '28 Mar 2026', folio: 'NX-20490', total: '$34,500.00', status: 'Pendiente' },
{ date: '21 Mar 2026', folio: 'NX-20370', total: '$22,800.00', status: 'Pagado' },
{ date: '13 Mar 2026', folio: 'NX-20160', total: '$48,900.00', status: 'Pagado' },
{ date: '05 Mar 2026', folio: 'NX-19950', total: '$16,700.00', status: 'Pagado' },
{ date: '24 Feb 2026', folio: 'NX-19700', total: '$39,200.00', status: 'Pagado' },
{ date: '14 Feb 2026', folio: 'NX-19460', total: '$27,600.00', status: 'Pagado' },
{ date: '04 Feb 2026', folio: 'NX-19220', total: '$11,400.00', status: 'Pagado' },
{ date: '26 Ene 2026', folio: 'NX-19000', total: '$53,800.00', status: 'Pagado' },
{ date: '17 Ene 2026', folio: 'NX-18810', total: '$24,100.00', status: 'Pagado' },
{ date: '08 Ene 2026', folio: 'NX-18620', total: '$31,600.00', status: 'Pagado' },
]
},
{
id: 11, num: '00011',
name: 'Centro Automotriz Garza', initials: 'CG',
rfc: 'GAR780904HNL', phone: '81 2244-5566', email: 'garza.auto@prodigy.net',
tipo: 'Taller', credit: 9000, creditLimit: 30000,
lastPurchase: '18 Mar 2026', estado: 'Activo',
address: 'Av. Constitución 1888, Monterrey, N.L.',
since: '07 Feb 2018',
history: [
{ date: '18 Mar 2026', folio: 'NX-20280', total: '$7,200.00', status: 'Pagado' },
{ date: '08 Mar 2026', folio: 'NX-20030', total: '$3,400.00', status: 'Pagado' },
{ date: '26 Feb 2026', folio: 'NX-19760', total: '$9,100.00', status: 'Pendiente' },
{ date: '16 Feb 2026', folio: 'NX-19510', total: '$4,800.00', status: 'Pagado' },
{ date: '06 Feb 2026', folio: 'NX-19270', total: '$6,550.00', status: 'Pagado' },
{ date: '27 Ene 2026', folio: 'NX-19040', total: '$2,100.00', status: 'Pagado' },
{ date: '17 Ene 2026', folio: 'NX-18820', total: '$8,300.00', status: 'Pagado' },
{ date: '08 Ene 2026', folio: 'NX-18630', total: '$3,670.00', status: 'Pagado' },
{ date: '29 Dic 2025', folio: 'NX-18250', total: '$11,400.00', status: 'Pagado' },
{ date: '18 Dic 2025', folio: 'NX-18020', total: '$5,890.00', status: 'Pagado' },
]
},
{
id: 12, num: '00012',
name: 'Refacciones El Farol', initials: 'RF',
rfc: 'REF011120B45', phone: '33 5577-8899', email: 'elfarol.ref@gmail.com',
tipo: 'Mostrador', credit: 7500, creditLimit: 10000,
lastPurchase: '17 Mar 2026', estado: 'Inactivo',
address: 'Calz. Independencia Sur 2340, Guadalajara, Jal.',
since: '15 Oct 2021',
history: [
{ date: '17 Mar 2026', folio: 'NX-20260', total: '$2,500.00', status: 'Pendiente' },
{ date: '06 Mar 2026', folio: 'NX-20010', total: '$1,800.00', status: 'Pagado' },
{ date: '23 Feb 2026', folio: 'NX-19680', total: '$3,200.00', status: 'Pagado' },
{ date: '12 Feb 2026', folio: 'NX-19440', total: '$900.00', status: 'Pagado' },
{ date: '01 Feb 2026', folio: 'NX-19200', total: '$4,100.00', status: 'Pagado' },
{ date: '21 Ene 2026', folio: 'NX-18960', total: '$1,550.00', status: 'Pagado' },
{ date: '11 Ene 2026', folio: 'NX-18770', total: '$2,800.00', status: 'Pagado' },
{ date: '02 Ene 2026', folio: 'NX-18540', total: '$680.00', status: 'Pagado' },
{ date: '21 Dic 2025', folio: 'NX-18170', total: '$3,600.00', status: 'Pagado' },
{ date: '10 Dic 2025', folio: 'NX-17970', total: '$1,200.00', status: 'Pagado' },
]
},
];
let selectedCustomerId = null;
let filteredCustomers = [...customers];
/* ------------------------------------------------------------------
RENDER TABLE
------------------------------------------------------------------ */
function renderTable(data) {
const tbody = document.getElementById('customersBody');
tbody.innerHTML = '';
if (data.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados para la búsqueda.</td></tr>`;
return;
}
data.forEach(c => {
const pct = Math.round(((c.creditLimit - c.credit) / c.creditLimit) * 100);
const creditClass = pct >= 80 ? 'none' : pct >= 60 ? 'low' : '';
const creditFormatted = new Intl.NumberFormat('es-MX', { style:'currency', currency:'MXN', minimumFractionDigits:0 }).format(c.credit);
let estadoBadge = '';
if (c.estado === 'Activo') estadoBadge = `<span class="badge badge--active"><span class="badge-dot"></span>Activo</span>`;
else if (c.estado === 'Inactivo') estadoBadge = `<span class="badge badge--inactive"><span class="badge-dot"></span>Inactivo</span>`;
else estadoBadge = `<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>`;
const tipoClass = c.tipo === 'Taller' ? 'tipo-chip--taller' : c.tipo === 'Mayoreo' ? 'tipo-chip--mayoreo' : 'tipo-chip--mostrador';
const isSelected = c.id === selectedCustomerId;
const tr = document.createElement('tr');
tr.className = isSelected ? 'selected' : '';
tr.onclick = () => selectCustomer(c.id);
tr.innerHTML = `
<td class="cell-num">${c.num}</td>
<td>
<div class="cell-name">${c.name}</div>
<div class="cell-name-sub hide-mobile">${c.email}</div>
</td>
<td class="cell-rfc hide-mobile">${c.rfc}</td>
<td class="hide-mobile">${c.phone}</td>
<td class="hide-mobile" style="font-size:var(--text-caption);color:var(--color-text-secondary);">${c.email}</td>
<td><span class="tipo-chip ${tipoClass}">${c.tipo}</span></td>
<td class="cell-credit ${creditClass}">${creditFormatted}</td>
<td class="cell-date hide-mobile">${c.lastPurchase}</td>
<td>${estadoBadge}</td>
`;
tbody.appendChild(tr);
});
}
/* ------------------------------------------------------------------
FILTER
------------------------------------------------------------------ */
function filterCustomers() {
const q = document.getElementById('searchInput').value.toLowerCase();
const tipo = document.getElementById('tipoFilter').value;
const estado = document.getElementById('estadoFilter').value;
filteredCustomers = customers.filter(c => {
const matchQ = !q || c.name.toLowerCase().includes(q) || c.rfc.toLowerCase().includes(q) || c.phone.includes(q) || c.email.toLowerCase().includes(q);
const matchT = !tipo || c.tipo === tipo;
const matchE = !estado || c.estado === estado;
return matchQ && matchT && matchE;
});
renderTable(filteredCustomers);
document.getElementById('tableInfo').textContent = `Mostrando ${filteredCustomers.length} de ${customers.length} clientes`;
}
/* ------------------------------------------------------------------
SELECT CUSTOMER — populate detail panel
------------------------------------------------------------------ */
function selectCustomer(id) {
const c = customers.find(x => x.id === id);
if (!c) return;
selectedCustomerId = id;
// Toggle empty/content
document.getElementById('detailEmpty').style.display = 'none';
const detail = document.getElementById('detailContent');
detail.style.display = 'flex';
// Avatar
document.getElementById('detailAvatar').textContent = c.initials;
// Header
document.getElementById('detailName').textContent = c.name.toUpperCase();
document.getElementById('detailRFC').textContent = c.rfc;
// Tipo chip
const tipoEl = document.getElementById('detailTipo');
tipoEl.textContent = c.tipo;
tipoEl.className = `tipo-chip tipo-chip--${c.tipo.toLowerCase()}`;
// Status badge
const statEl = document.getElementById('detailStatus');
if (c.estado === 'Activo') {
statEl.className = 'badge badge--active';
statEl.innerHTML = '<span class="badge-dot"></span>Activo';
} else if (c.estado === 'Inactivo') {
statEl.className = 'badge badge--inactive';
statEl.innerHTML = '<span class="badge-dot"></span>Inactivo';
} else {
statEl.className = 'badge badge--warning';
statEl.innerHTML = '<span class="badge-dot"></span>En Mora';
}
// Contact
document.getElementById('detailAddress').textContent = c.address;
document.getElementById('detailPhone').textContent = c.phone;
document.getElementById('detailEmail').textContent = c.email;
document.getElementById('detailSince').textContent = c.since;
document.getElementById('detailLastPurchase').textContent = c.lastPurchase;
// Credit
const fmt = n => new Intl.NumberFormat('es-MX', { style:'currency', currency:'MXN', minimumFractionDigits:0 }).format(n);
const used = c.creditLimit - c.credit;
const pct = Math.round((used / c.creditLimit) * 100);
document.getElementById('detailCreditLimit').textContent = fmt(c.creditLimit);
document.getElementById('detailCreditAvail').textContent = fmt(c.credit);
document.getElementById('detailCreditUsed').textContent = fmt(used);
document.getElementById('detailCreditPct').textContent = `${pct}%`;
const bar = document.getElementById('detailCreditBar');
bar.style.width = `${pct}%`;
bar.className = `progress-bar__fill ${pct < 40 ? 'low' : pct > 75 ? 'high' : ''}`;
// Purchase History
const hbody = document.getElementById('historyBody');
hbody.innerHTML = '';
c.history.forEach(h => {
const statusClass = h.status === 'Pagado' ? 'mbadge--paid' : h.status === 'Vencido' ? 'mbadge--overdue' : 'mbadge--pending';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="date">${h.date}</td>
<td class="folio">${h.folio}</td>
<td class="total">${h.total}</td>
<td><span class="mbadge ${statusClass}">${h.status}</span></td>
`;
hbody.appendChild(tr);
});
// Re-render table to update selected row highlight
renderTable(filteredCustomers);
}
function closeDetail() {
selectedCustomerId = null;
document.getElementById('detailEmpty').style.display = 'flex';
document.getElementById('detailContent').style.display = 'none';
renderTable(filteredCustomers);
}
function openNewCustomerModal() {
alert('Modal "Nuevo Cliente" — pendiente de implementación en sprint 2.');
}
/* ------------------------------------------------------------------
INIT
------------------------------------------------------------------ */
renderTable(customers);
// Pre-select first customer on load for demo
selectCustomer(1);
/* ------------------------------------------------------------------
SLIDE PANEL — wired to Customers.showDetail()
SLIDE PANEL
------------------------------------------------------------------ */
const panelOverlay = document.getElementById('panelOverlay');
const slidePanel = document.getElementById('slidePanel');
@@ -2487,18 +2066,25 @@
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSlidePanel();
if (e.key === 'Escape') {
closeSlidePanel();
if (typeof Customers !== 'undefined') Customers.closeModal();
}
});
// Override viewCustomer / showDetail to open the slide panel
window.viewCustomer = function(id) {
if (typeof Customers !== 'undefined' && Customers.showDetail) {
Customers.showDetail(id);
}
/* Placeholder — overridden by customers.js init */
function openNewCustomerModal() {}
function filterCustomers() {}
function closeDetail() {
if (typeof Customers !== 'undefined') Customers.closeDetail();
}
function viewCustomer(id) {
if (typeof Customers !== 'undefined') Customers.showDetail(id);
openSlidePanel();
};
}
</script>
<!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
<span class="banner__icon"></span>