Files
Autoparts-DB/pos/static/js/customers.js
consultoria-as a236187f3a feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
2026-05-26 04:24:07 +00:00

803 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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 '<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;
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 '<tr class="' + selClass + '" onclick="Customers.selectCustomer(' + c.id + ')">' +
'<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>' +
'</tr>';
}
var customersVS = null;
function renderTable(customers) {
const tbody = document.getElementById('customersBody');
if (!tbody) return;
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;
}
if (!customersVS) {
customersVS = new VirtualScroll({
container: tbody,
rowHeight: 52,
buffer: 3,
renderRow: renderCustomerRow,
emptyHtml: '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>'
});
}
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 += `<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>`;
}
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);
}
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 = '<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) {
vehiclesEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin vehiculos registrados</span>';
} else {
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="color:var(--color-text-muted);">Placas: ${v.plates}</span>` : ''}
</div>`
).join('');
}
}
// Purchases
const purchasesEl = document.getElementById('panelPurchases');
if (purchasesEl) {
const purchases = c.recent_purchases || [];
if (purchases.length === 0) {
purchasesEl.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin compras recientes</span>';
} else {
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('');
}
}
}
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 = '<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 ? data.customer.credit_limit : 0)}
</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: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 => {
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>
</tr>`;
});
html += '</table>';
}
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 ────────────────────────────
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 = `
<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 class="form-group"><label>Descuento Max (%)</label><input type="number" id="fMaxDiscountPct" class="form-input" value="0" min="0" max="100" step="0.5" /></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
const existingStatement = document.getElementById('statementModal');
if (existingStatement) existingStatement.remove();
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();
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;
})();