Compare commits
3 Commits
3ea6f181ff
...
56fed52253
| Author | SHA1 | Date | |
|---|---|---|---|
| 56fed52253 | |||
| 004cca7ccc | |||
| 761e56e87f |
@@ -1,10 +1,9 @@
|
||||
// /home/Autopartes/pos/static/js/accounting.js
|
||||
// Accounting module: chart of accounts, journal entries, financial reports
|
||||
// Accounting module — wired to design-system HTML IDs
|
||||
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
|
||||
|
||||
const Accounting = (() => {
|
||||
const API = '/pos/api/accounting';
|
||||
let accounts = []; // cached for dropdowns
|
||||
let entryLineCtr = 0; // counter for entry line IDs
|
||||
|
||||
function token() {
|
||||
return localStorage.getItem('pos_token') || '';
|
||||
@@ -27,567 +26,373 @@ const Accounting = (() => {
|
||||
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// ─── Tabs ──────────────────────────────────────
|
||||
// ---- Auth check ----
|
||||
function checkAuth() {
|
||||
if (!token()) {
|
||||
window.location.href = '/pos/login';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||||
|
||||
// Load data on tab switch
|
||||
const t = tab.dataset.tab;
|
||||
if (t === 'accounts') loadAccounts();
|
||||
if (t === 'entries') loadEntries();
|
||||
if (t === 'trial-balance') loadTrialBalance();
|
||||
if (t === 'income-statement') loadIncomeStatement();
|
||||
if (t === 'balance-sheet') loadBalanceSheet();
|
||||
if (t === 'aging') loadAging();
|
||||
if (t === 'periods') loadPeriods();
|
||||
});
|
||||
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('is-active');
|
||||
btn.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('is-active'));
|
||||
|
||||
// ─── Chart of Accounts ─────────────────────────
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
const res = await api('/accounts');
|
||||
accounts = res.data || [];
|
||||
renderAccountsTree();
|
||||
} catch (e) {
|
||||
document.getElementById('accounts-tree').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccountsTree() {
|
||||
const container = document.getElementById('accounts-tree');
|
||||
if (!accounts.length) { container.innerHTML = '<p>No hay cuentas.</p>'; return; }
|
||||
|
||||
// Build tree structure
|
||||
const byParent = {};
|
||||
accounts.forEach(a => {
|
||||
const pid = a.parent_id || 'root';
|
||||
if (!byParent[pid]) byParent[pid] = [];
|
||||
byParent[pid].push(a);
|
||||
});
|
||||
|
||||
function buildUl(parentId) {
|
||||
const children = byParent[parentId] || [];
|
||||
if (!children.length) return '';
|
||||
let html = '<ul class="tree">';
|
||||
for (const acct of children) {
|
||||
const hasChildren = byParent[acct.id] && byParent[acct.id].length > 0;
|
||||
const balClass = acct.balance < 0 ? 'negative' : '';
|
||||
html += '<li>';
|
||||
if (hasChildren) {
|
||||
html += `<span class="tree-toggle open" onclick="this.classList.toggle('open');this.nextElementSibling.style.display=this.classList.contains('open')?'block':'none'">`;
|
||||
} else {
|
||||
html += '<span style="display:inline-block;width:1rem;">';
|
||||
}
|
||||
html += `<span class="tree-code">${acct.code}</span> ${acct.name}`;
|
||||
html += `<span class="tree-balance ${balClass}">$${fmt(acct.balance)}</span>`;
|
||||
html += '</span>';
|
||||
if (hasChildren) {
|
||||
html += `<div>${buildUl(acct.id)}</div>`;
|
||||
}
|
||||
html += '</li>';
|
||||
}
|
||||
html += '</ul>';
|
||||
return html;
|
||||
// Activate button
|
||||
const tabBtn = document.querySelector(`.tab-btn[onclick*="'${name}'"]`);
|
||||
if (tabBtn) {
|
||||
tabBtn.classList.add('is-active');
|
||||
tabBtn.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
|
||||
container.innerHTML = buildUl('root');
|
||||
// Activate panel
|
||||
const panel = document.getElementById(`panel-${name}`);
|
||||
if (panel) panel.classList.add('is-active');
|
||||
|
||||
// Load data
|
||||
if (name === 'cxc') loadAging();
|
||||
if (name === 'cxp') loadAccountsPayable();
|
||||
if (name === 'balance') loadBalanceSheet();
|
||||
if (name === 'resultados') loadIncomeStatement();
|
||||
if (name === 'flujo') loadCashFlow();
|
||||
if (name === 'conciliacion') loadReconciliation();
|
||||
if (name === 'cierre') loadPeriodClose();
|
||||
}
|
||||
|
||||
function showNewAccountModal() {
|
||||
const sel = document.getElementById('na-parent');
|
||||
sel.innerHTML = '<option value="">-- Sin padre --</option>';
|
||||
accounts.forEach(a => {
|
||||
sel.innerHTML += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
|
||||
});
|
||||
document.getElementById('na-code').value = '';
|
||||
document.getElementById('na-name').value = '';
|
||||
document.getElementById('new-account-modal').classList.add('active');
|
||||
// ---- Badge helper ----
|
||||
function statusBadge(status, label) {
|
||||
const map = {
|
||||
pending: 'badge--pending',
|
||||
vigente: 'badge--pending',
|
||||
overdue: 'badge--overdue',
|
||||
vencida: 'badge--overdue',
|
||||
partial: 'badge--partial',
|
||||
parcial: 'badge--partial',
|
||||
ok: 'badge--ok',
|
||||
pagada: 'badge--ok',
|
||||
open: 'badge--pending',
|
||||
closed: 'badge--ok',
|
||||
};
|
||||
return `<span class="badge ${map[status] || ''}">${label || status}</span>`;
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
try {
|
||||
const parentId = document.getElementById('na-parent').value;
|
||||
await api('/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: document.getElementById('na-code').value,
|
||||
name: document.getElementById('na-name').value,
|
||||
parent_id: parentId ? parseInt(parentId) : null,
|
||||
type: document.getElementById('na-type').value,
|
||||
}),
|
||||
});
|
||||
closeModal('new-account-modal');
|
||||
loadAccounts();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Journal Entries ───────────────────────────
|
||||
|
||||
async function loadEntries() {
|
||||
try {
|
||||
const from = document.getElementById('entries-from').value;
|
||||
const to = document.getElementById('entries-to').value;
|
||||
const type = document.getElementById('entries-type').value;
|
||||
let qs = '?per_page=50';
|
||||
if (from) qs += `&date_from=${from}`;
|
||||
if (to) qs += `&date_to=${to}`;
|
||||
if (type) qs += `&type=${type}`;
|
||||
|
||||
const res = await api(`/entries${qs}`);
|
||||
renderEntries(res.data || []);
|
||||
} catch (e) {
|
||||
document.getElementById('entries-list').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntries(entries) {
|
||||
const container = document.getElementById('entries-list');
|
||||
if (!entries.length) { container.innerHTML = '<p>No hay polizas en este periodo.</p>'; return; }
|
||||
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>#</th><th>Fecha</th><th>Tipo</th><th>Descripcion</th>
|
||||
<th>Referencia</th><th class="amount">Monto</th><th>Auto</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const e of entries) {
|
||||
html += `<tr style="cursor:pointer;" onclick="Accounting.showEntryDetail(${e.id})">
|
||||
<td>${e.entry_number}</td>
|
||||
<td>${e.date || ''}</td>
|
||||
<td>${e.type || ''}</td>
|
||||
<td>${e.description || ''}</td>
|
||||
<td>${e.reference_type ? `${e.reference_type} #${e.reference_id}` : ''}</td>
|
||||
<td class="amount">$${fmt(e.total_amount)}</td>
|
||||
<td>${e.is_auto ? 'Si' : 'No'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function showEntryDetail(entryId) {
|
||||
try {
|
||||
const entry = await api(`/entries/${entryId}`);
|
||||
let html = `<h3>Poliza #${entry.entry_number}</h3>
|
||||
<div class="entry-header">
|
||||
<span><strong>Fecha:</strong> ${entry.date}</span>
|
||||
<span><strong>Tipo:</strong> ${entry.type}</span>
|
||||
<span><strong>Estado:</strong> ${entry.status}</span>
|
||||
</div>
|
||||
<p>${entry.description || ''}</p>
|
||||
<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>Cuenta</th><th>Nombre</th><th class="amount">Cargo</th>
|
||||
<th class="amount">Abono</th><th>Nota</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const l of entry.lines) {
|
||||
html += `<tr>
|
||||
<td class="tree-code">${l.account_code}</td>
|
||||
<td>${l.account_name}</td>
|
||||
<td class="amount">${l.debit ? '$' + fmt(l.debit) : ''}</td>
|
||||
<td class="amount">${l.credit ? '$' + fmt(l.credit) : ''}</td>
|
||||
<td>${l.description || ''}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `<tr class="total-row">
|
||||
<td colspan="2">Totales</td>
|
||||
<td class="amount">$${fmt(entry.total_debit)}</td>
|
||||
<td class="amount">$${fmt(entry.total_credit)}</td>
|
||||
<td></td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('entry-detail-content').innerHTML = html;
|
||||
document.getElementById('entry-detail-modal').classList.add('active');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual Entry ──────────────────────────────
|
||||
|
||||
function showNewEntryModal() {
|
||||
document.getElementById('ne-date').value = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('ne-description').value = '';
|
||||
document.getElementById('ne-lines').innerHTML = '';
|
||||
document.getElementById('ne-balance').innerHTML = '';
|
||||
entryLineCtr = 0;
|
||||
addEntryLine();
|
||||
addEntryLine();
|
||||
document.getElementById('new-entry-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function addEntryLine() {
|
||||
const id = entryLineCtr++;
|
||||
const tbody = document.getElementById('ne-lines');
|
||||
const tr = document.createElement('tr');
|
||||
tr.id = `ne-line-${id}`;
|
||||
|
||||
let acctOptions = '<option value="">Seleccionar</option>';
|
||||
accounts.forEach(a => {
|
||||
if (a.parent_id) { // Only leaf accounts
|
||||
acctOptions += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
|
||||
}
|
||||
});
|
||||
|
||||
tr.innerHTML = `
|
||||
<td><select onchange="Accounting.updateEntryBalance()">${acctOptions}</select></td>
|
||||
<td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
|
||||
<td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
|
||||
<td><button onclick="this.closest('tr').remove();Accounting.updateEntryBalance();"
|
||||
style="border:none;background:none;color:var(--danger);cursor:pointer;font-size:1.1rem;">x</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function updateEntryBalance() {
|
||||
const rows = document.querySelectorAll('#ne-lines tr');
|
||||
let totalDebit = 0, totalCredit = 0;
|
||||
rows.forEach(row => {
|
||||
const inputs = row.querySelectorAll('input[type="number"]');
|
||||
totalDebit += parseFloat(inputs[0].value) || 0;
|
||||
totalCredit += parseFloat(inputs[1].value) || 0;
|
||||
});
|
||||
const diff = Math.round((totalDebit - totalCredit) * 100) / 100;
|
||||
const el = document.getElementById('ne-balance');
|
||||
if (diff === 0) {
|
||||
el.innerHTML = `<span style="color:var(--success);">Cuadrada: Cargos $${fmt(totalDebit)} = Abonos $${fmt(totalCredit)}</span>`;
|
||||
} else {
|
||||
el.innerHTML = `<span style="color:var(--danger);">Descuadre: $${fmt(Math.abs(diff))} (Cargos $${fmt(totalDebit)} / Abonos $${fmt(totalCredit)})</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntry() {
|
||||
try {
|
||||
const rows = document.querySelectorAll('#ne-lines tr');
|
||||
const lines = [];
|
||||
rows.forEach(row => {
|
||||
const sel = row.querySelector('select');
|
||||
const inputs = row.querySelectorAll('input[type="number"]');
|
||||
const acctId = parseInt(sel.value);
|
||||
const debit = parseFloat(inputs[0].value) || 0;
|
||||
const credit = parseFloat(inputs[1].value) || 0;
|
||||
if (acctId && (debit > 0 || credit > 0)) {
|
||||
lines.push({ account_id: acctId, debit, credit, description: '' });
|
||||
}
|
||||
});
|
||||
|
||||
if (lines.length < 2) { alert('Se requieren al menos 2 lineas.'); return; }
|
||||
|
||||
await api('/entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
date: document.getElementById('ne-date').value,
|
||||
description: document.getElementById('ne-description').value,
|
||||
lines,
|
||||
}),
|
||||
});
|
||||
closeModal('new-entry-modal');
|
||||
loadEntries();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trial Balance ─────────────────────────────
|
||||
|
||||
async function loadTrialBalance() {
|
||||
try {
|
||||
const year = document.getElementById('tb-year').value;
|
||||
const month = document.getElementById('tb-month').value;
|
||||
const res = await api(`/trial-balance?year=${year}&month=${month}`);
|
||||
renderTrialBalance(res);
|
||||
} catch (e) {
|
||||
document.getElementById('trial-balance-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTrialBalance(data) {
|
||||
const rows = data.data || [];
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>Codigo</th><th>Cuenta</th>
|
||||
<th class="amount">Saldo Inicial</th><th class="amount">Cargos</th>
|
||||
<th class="amount">Abonos</th><th class="amount">Saldo Final</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
let totals = { si: 0, c: 0, a: 0, sf: 0 };
|
||||
for (const r of rows) {
|
||||
totals.si += r.saldo_inicial;
|
||||
totals.c += r.cargos;
|
||||
totals.a += r.abonos;
|
||||
totals.sf += r.saldo_final;
|
||||
html += `<tr>
|
||||
<td class="tree-code">${r.code}</td><td>${r.name}</td>
|
||||
<td class="amount">$${fmt(r.saldo_inicial)}</td>
|
||||
<td class="amount">$${fmt(r.cargos)}</td>
|
||||
<td class="amount">$${fmt(r.abonos)}</td>
|
||||
<td class="amount">$${fmt(r.saldo_final)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `<tr class="total-row">
|
||||
<td colspan="2">Totales</td>
|
||||
<td class="amount">$${fmt(totals.si)}</td>
|
||||
<td class="amount">$${fmt(totals.c)}</td>
|
||||
<td class="amount">$${fmt(totals.a)}</td>
|
||||
<td class="amount">$${fmt(totals.sf)}</td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('trial-balance-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Income Statement ──────────────────────────
|
||||
|
||||
async function loadIncomeStatement() {
|
||||
try {
|
||||
const year = document.getElementById('is-year').value;
|
||||
const month = document.getElementById('is-month').value;
|
||||
const res = await api(`/income-statement?year=${year}&month=${month}`);
|
||||
renderIncomeStatement(res);
|
||||
} catch (e) {
|
||||
document.getElementById('income-statement-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIncomeStatement(data) {
|
||||
let html = '<table class="report-table"><tbody>';
|
||||
|
||||
// Ingresos
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>INGRESOS</strong></td></tr>';
|
||||
for (const item of data.ingresos.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="subtotal-row"><td>Total Ingresos</td><td class="amount">$${fmt(data.ingresos.total)}</td></tr>`;
|
||||
|
||||
// Costos
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>COSTOS</strong></td></tr>';
|
||||
for (const item of data.costos.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="subtotal-row"><td>Total Costos</td><td class="amount">$${fmt(data.costos.total)}</td></tr>`;
|
||||
|
||||
// Utilidad bruta
|
||||
html += `<tr class="total-row"><td><strong>UTILIDAD BRUTA</strong></td><td class="amount"><strong>$${fmt(data.utilidad_bruta)}</strong></td></tr>`;
|
||||
|
||||
// Gastos
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>GASTOS</strong></td></tr>';
|
||||
for (const item of data.gastos.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="subtotal-row"><td>Total Gastos</td><td class="amount">$${fmt(data.gastos.total)}</td></tr>`;
|
||||
|
||||
// Utilidad neta
|
||||
const netColor = data.utilidad_neta >= 0 ? 'var(--success)' : 'var(--danger)';
|
||||
html += `<tr class="total-row"><td><strong>UTILIDAD NETA</strong></td>
|
||||
<td class="amount" style="color:${netColor};"><strong>$${fmt(data.utilidad_neta)}</strong></td></tr>`;
|
||||
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('income-statement-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Balance Sheet ─────────────────────────────
|
||||
|
||||
async function loadBalanceSheet() {
|
||||
try {
|
||||
const asOf = document.getElementById('bs-date').value;
|
||||
const res = await api(`/balance-sheet?date=${asOf}`);
|
||||
renderBalanceSheet(res);
|
||||
} catch (e) {
|
||||
document.getElementById('balance-sheet-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBalanceSheet(data) {
|
||||
const balancedBadge = data.balanced
|
||||
? '<span class="badge badge-open">Cuadrado</span>'
|
||||
: '<span class="badge badge-closed">Descuadrado</span>';
|
||||
|
||||
let html = `<p>Al ${data.as_of} ${balancedBadge}</p>
|
||||
<table class="report-table"><tbody>`;
|
||||
|
||||
// Activo
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>ACTIVO</strong></td></tr>';
|
||||
for (const item of data.activo.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="total-row"><td>Total Activo</td><td class="amount">$${fmt(data.activo.total)}</td></tr>`;
|
||||
|
||||
// Pasivo
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>PASIVO</strong></td></tr>';
|
||||
for (const item of data.pasivo.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="total-row"><td>Total Pasivo</td><td class="amount">$${fmt(data.pasivo.total)}</td></tr>`;
|
||||
|
||||
// Capital
|
||||
html += '<tr class="subtotal-row"><td colspan="2"><strong>CAPITAL</strong></td></tr>';
|
||||
for (const item of data.capital.items) {
|
||||
html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
|
||||
}
|
||||
html += `<tr class="total-row"><td>Total Capital</td><td class="amount">$${fmt(data.capital.total)}</td></tr>`;
|
||||
|
||||
html += `<tr class="total-row"><td><strong>Pasivo + Capital</strong></td>
|
||||
<td class="amount"><strong>$${fmt(data.pasivo.total + data.capital.total)}</strong></td></tr>`;
|
||||
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('balance-sheet-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Aging ─────────────────────────────────────
|
||||
|
||||
// ---- Tab 1: Cuentas por Cobrar (Aging) ----
|
||||
async function loadAging() {
|
||||
const panel = document.getElementById('panel-cxc');
|
||||
if (!panel) return;
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
const res = await api('/aging');
|
||||
renderAging(res);
|
||||
const rows = res.data || [];
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por cobrar.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
|
||||
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
|
||||
return `<tr>
|
||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||
<td class="td--primary">${r.name || r.customer_name || '-'}</td>
|
||||
<td>${r.issue_date ? new Date(r.issue_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td class="td--amount">$${fmt(r.total)}</td>
|
||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||
<td>${statusBadge(status, label)}</td>
|
||||
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Cobrar' : 'Ver'}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Update pagination text
|
||||
const pagSpan = panel.querySelector('.pagination span');
|
||||
if (pagSpan) pagSpan.textContent = `Mostrando 1-${rows.length} de ${res.totals?.count || rows.length} registros`;
|
||||
|
||||
// Update summary card badge count
|
||||
updateBadgeCount('cxc', rows.length);
|
||||
} catch (e) {
|
||||
document.getElementById('aging-content').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
tbody.innerHTML = `<tr><td colspan="9" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAging(data) {
|
||||
const rows = data.data || [];
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr>
|
||||
<th>Cliente</th><th class="amount">Corriente</th>
|
||||
<th class="amount">1-30d</th><th class="amount">31-60d</th>
|
||||
<th class="amount">61-90d</th><th class="amount">90+d</th>
|
||||
<th class="amount">Total</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const r of rows) {
|
||||
html += `<tr>
|
||||
<td>${r.name}</td>
|
||||
<td class="amount">$${fmt(r.corriente)}</td>
|
||||
<td class="amount">$${fmt(r.d1_30)}</td>
|
||||
<td class="amount">$${fmt(r.d31_60)}</td>
|
||||
<td class="amount">$${fmt(r.d61_90)}</td>
|
||||
<td class="amount">$${fmt(r.d90_plus)}</td>
|
||||
<td class="amount"><strong>$${fmt(r.total)}</strong></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const t = data.totals || {};
|
||||
html += `<tr class="total-row">
|
||||
<td>Totales</td>
|
||||
<td class="amount">$${fmt(t.corriente)}</td>
|
||||
<td class="amount">$${fmt(t.d1_30)}</td>
|
||||
<td class="amount">$${fmt(t.d31_60)}</td>
|
||||
<td class="amount">$${fmt(t.d61_90)}</td>
|
||||
<td class="amount">$${fmt(t.d90_plus)}</td>
|
||||
<td class="amount"><strong>$${fmt(t.total)}</strong></td>
|
||||
</tr></tbody></table>`;
|
||||
|
||||
document.getElementById('aging-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Periods ───────────────────────────────────
|
||||
|
||||
async function loadPeriods() {
|
||||
try {
|
||||
const res = await api('/periods');
|
||||
renderPeriods(res.data || []);
|
||||
} catch (e) {
|
||||
document.getElementById('periods-list').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPeriods(periods) {
|
||||
const months = ['', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
let html = `<table class="report-table">
|
||||
<thead><tr><th>Periodo</th><th>Estado</th><th>Cerrado por</th><th>Fecha cierre</th></tr></thead><tbody>`;
|
||||
|
||||
for (const p of periods) {
|
||||
const badge = p.status === 'closed'
|
||||
? '<span class="badge badge-closed">Cerrado</span>'
|
||||
: '<span class="badge badge-open">Abierto</span>';
|
||||
html += `<tr>
|
||||
<td>${months[p.month]} ${p.year}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${p.closed_by_name || '-'}</td>
|
||||
<td>${p.closed_at ? new Date(p.closed_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('periods-list').innerHTML = html;
|
||||
}
|
||||
|
||||
async function closePeriod() {
|
||||
const year = parseInt(document.getElementById('cp-year').value);
|
||||
const month = parseInt(document.getElementById('cp-month').value);
|
||||
if (!confirm(`Cerrar periodo ${month}/${year}? Esta accion no se puede revertir.`)) return;
|
||||
// ---- Tab 2: Cuentas por Pagar ----
|
||||
async function loadAccountsPayable() {
|
||||
const panel = document.getElementById('panel-cxp');
|
||||
if (!panel) return;
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
await api('/periods/close', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year, month }),
|
||||
// Use accounts endpoint filtered for payables or a dedicated endpoint
|
||||
const res = await api('/aging?type=payable');
|
||||
const rows = res.data || [];
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por pagar.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const status = r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending';
|
||||
const label = status === 'overdue' ? 'Vencida' : status === 'partial' ? 'Parcial' : status === 'ok' ? 'Pagada' : 'Vigente';
|
||||
return `<tr>
|
||||
<td class="td--mono">${r.invoice || r.folio || '-'}</td>
|
||||
<td class="td--primary">${r.name || r.vendor_name || '-'}</td>
|
||||
<td>${r.receipt_date ? new Date(r.receipt_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td class="td--amount">$${fmt(r.total)}</td>
|
||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||
<td>${statusBadge(status, label)}</td>
|
||||
<td><button class="btn btn--ghost btn--sm">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
const pagSpan = panel.querySelector('.pagination span');
|
||||
if (pagSpan) pagSpan.textContent = `Mostrando 1-${rows.length} de ${res.totals?.count || rows.length} registros`;
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tab 3: Balance General ----
|
||||
async function loadBalanceSheet() {
|
||||
const panel = document.getElementById('panel-balance');
|
||||
if (!panel) return;
|
||||
const grid = panel.querySelector('.finance-grid');
|
||||
if (!grid) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const res = await api(`/balance-sheet?date=${now.toISOString().slice(0, 10)}`);
|
||||
|
||||
// Build assets card
|
||||
const activoCard = grid.querySelector('.finance-card:first-child');
|
||||
const pasivoCard = grid.querySelector('.finance-card:last-child');
|
||||
|
||||
if (activoCard) {
|
||||
let html = `<div class="finance-card__header"><div class="finance-card__title">Activos</div></div>`;
|
||||
if (res.activo && res.activo.items) {
|
||||
for (const item of res.activo.items) {
|
||||
const isNeg = item.balance < 0;
|
||||
html += `<div class="finance-card__row finance-card__row--indent">
|
||||
<span class="finance-card__row-label">${item.code ? item.code + ' ' : ''}${item.name}</span>
|
||||
<span class="finance-card__row-value ${isNeg ? 'finance-card__row-value--negative' : ''}">$${fmt(item.balance)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += `<div class="finance-card__row finance-card__row--total">
|
||||
<span class="finance-card__row-label">Total Activos</span>
|
||||
<span class="finance-card__row-value">$${fmt(res.activo?.total || 0)}</span>
|
||||
</div>`;
|
||||
activoCard.innerHTML = html;
|
||||
}
|
||||
|
||||
if (pasivoCard) {
|
||||
let html = `<div class="finance-card__header"><div class="finance-card__title">Pasivo + Capital</div></div>`;
|
||||
// Pasivo
|
||||
if (res.pasivo && res.pasivo.items) {
|
||||
html += `<div class="finance-card__row finance-card__row--section"><span class="finance-card__row-label">Pasivo</span><span></span></div>`;
|
||||
for (const item of res.pasivo.items) {
|
||||
html += `<div class="finance-card__row finance-card__row--indent">
|
||||
<span class="finance-card__row-label">${item.code ? item.code + ' ' : ''}${item.name}</span>
|
||||
<span class="finance-card__row-value">$${fmt(item.balance)}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `<div class="finance-card__row"><span class="finance-card__row-label" style="font-weight:var(--font-weight-semibold)">Total Pasivo</span><span class="finance-card__row-value">$${fmt(res.pasivo.total)}</span></div>`;
|
||||
}
|
||||
// Capital
|
||||
if (res.capital && res.capital.items) {
|
||||
html += `<div class="finance-card__row finance-card__row--section"><span class="finance-card__row-label">Capital Contable</span><span></span></div>`;
|
||||
for (const item of res.capital.items) {
|
||||
const isPos = item.balance > 0;
|
||||
html += `<div class="finance-card__row finance-card__row--indent">
|
||||
<span class="finance-card__row-label">${item.code ? item.code + ' ' : ''}${item.name}</span>
|
||||
<span class="finance-card__row-value ${isPos ? 'finance-card__row-value--positive' : ''}">$${fmt(item.balance)}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `<div class="finance-card__row"><span class="finance-card__row-label" style="font-weight:var(--font-weight-semibold)">Total Capital</span><span class="finance-card__row-value">$${fmt(res.capital.total)}</span></div>`;
|
||||
}
|
||||
html += `<div class="finance-card__row finance-card__row--total">
|
||||
<span class="finance-card__row-label">Total Pasivo + Capital</span>
|
||||
<span class="finance-card__row-value">$${fmt((res.pasivo?.total || 0) + (res.capital?.total || 0))}</span>
|
||||
</div>`;
|
||||
pasivoCard.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update period selector text
|
||||
const sel = panel.querySelector('.select-filter');
|
||||
if (sel && res.as_of) {
|
||||
sel.innerHTML = `<option>Al ${res.as_of}</option>`;
|
||||
}
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tab 4: Estado de Resultados ----
|
||||
async function loadIncomeStatement() {
|
||||
const panel = document.getElementById('panel-resultados');
|
||||
if (!panel) return;
|
||||
const grid = panel.querySelector('.finance-grid');
|
||||
if (!grid) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const res = await api(`/income-statement?year=${now.getFullYear()}&month=${now.getMonth() + 1}`);
|
||||
|
||||
const card = grid.querySelector('.finance-card');
|
||||
if (!card) return;
|
||||
|
||||
let html = `<div class="finance-card__header"><div class="finance-card__title">Estado de Resultados</div></div>`;
|
||||
|
||||
// Ingresos
|
||||
html += `<div class="finance-card__row finance-card__row--section"><span class="finance-card__row-label">Ingresos</span><span></span></div>`;
|
||||
if (res.ingresos && res.ingresos.items) {
|
||||
for (const item of res.ingresos.items) {
|
||||
html += `<div class="finance-card__row finance-card__row--indent">
|
||||
<span class="finance-card__row-label">${item.name}</span>
|
||||
<span class="finance-card__row-value">$${fmt(item.amount)}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += `<div class="finance-card__row"><span class="finance-card__row-label" style="font-weight:var(--font-weight-semibold)">Total Ingresos</span><span class="finance-card__row-value">$${fmt(res.ingresos?.total || 0)}</span></div>`;
|
||||
|
||||
// Costos
|
||||
html += `<div class="finance-card__row finance-card__row--section"><span class="finance-card__row-label">Costo de Ventas</span><span></span></div>`;
|
||||
if (res.costos && res.costos.items) {
|
||||
for (const item of res.costos.items) {
|
||||
html += `<div class="finance-card__row finance-card__row--indent">
|
||||
<span class="finance-card__row-label">${item.name}</span>
|
||||
<span class="finance-card__row-value finance-card__row-value--negative">-$${fmt(Math.abs(item.amount))}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += `<div class="finance-card__row"><span class="finance-card__row-label" style="font-weight:var(--font-weight-semibold)">Utilidad Bruta</span><span class="finance-card__row-value finance-card__row-value--positive">$${fmt(res.utilidad_bruta || 0)}</span></div>`;
|
||||
|
||||
// Gastos
|
||||
html += `<div class="finance-card__row finance-card__row--section"><span class="finance-card__row-label">Gastos de Operacion</span><span></span></div>`;
|
||||
if (res.gastos && res.gastos.items) {
|
||||
for (const item of res.gastos.items) {
|
||||
html += `<div class="finance-card__row finance-card__row--indent">
|
||||
<span class="finance-card__row-label">${item.name}</span>
|
||||
<span class="finance-card__row-value finance-card__row-value--negative">-$${fmt(Math.abs(item.amount))}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
html += `<div class="finance-card__row"><span class="finance-card__row-label" style="font-weight:var(--font-weight-semibold)">Total Gastos Operacion</span><span class="finance-card__row-value finance-card__row-value--negative">-$${fmt(Math.abs(res.gastos?.total || 0))}</span></div>`;
|
||||
|
||||
// Utilidad neta
|
||||
const netColor = (res.utilidad_neta || 0) >= 0 ? 'finance-card__row-value--positive' : 'finance-card__row-value--negative';
|
||||
html += `<div class="finance-card__row finance-card__row--total">
|
||||
<span class="finance-card__row-label">Utilidad Neta</span>
|
||||
<span class="finance-card__row-value ${netColor}">$${fmt(res.utilidad_neta || 0)}</span>
|
||||
</div>`;
|
||||
|
||||
card.innerHTML = html;
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tab 5: Flujo de Efectivo ----
|
||||
async function loadCashFlow() {
|
||||
// Flujo de Efectivo currently has no dedicated API endpoint, keep demo data
|
||||
// Future: wire to /pos/api/accounting/cash-flow
|
||||
}
|
||||
|
||||
// ---- Tab 6: Conciliacion Bancaria ----
|
||||
async function loadReconciliation() {
|
||||
// Bank reconciliation currently has no dedicated API endpoint, keep demo data
|
||||
// Future: wire to /pos/api/accounting/reconciliation
|
||||
}
|
||||
|
||||
// ---- Tab 7: Cierre de Mes ----
|
||||
async function loadPeriodClose() {
|
||||
const panel = document.getElementById('panel-cierre');
|
||||
if (!panel) return;
|
||||
|
||||
// Wire the "Ejecutar Cierre" button
|
||||
const closeBtn = panel.querySelector('.btn--primary');
|
||||
if (closeBtn && !closeBtn.dataset.wired) {
|
||||
closeBtn.dataset.wired = 'true';
|
||||
closeBtn.addEventListener('click', async () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
if (!confirm(`Cerrar periodo ${month}/${year}? Esta accion no se puede revertir.`)) return;
|
||||
try {
|
||||
await api('/periods/close', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year, month }),
|
||||
});
|
||||
alert('Periodo cerrado exitosamente.');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
});
|
||||
loadPeriods();
|
||||
alert('Periodo cerrado exitosamente.');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal helpers ─────────────────────────────
|
||||
// ---- Summary cards update ----
|
||||
async function loadSummaryCards() {
|
||||
const cards = document.querySelectorAll('.summary-card');
|
||||
if (cards.length < 4) return;
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
try {
|
||||
// Load trial balance for overall numbers
|
||||
const now = new Date();
|
||||
const tb = await api(`/trial-balance?year=${now.getFullYear()}&month=${now.getMonth() + 1}`);
|
||||
// The summary cards will keep their structure, just update values if API returns data
|
||||
// This is best-effort; if API doesn't support summary data, demo values remain
|
||||
} catch (_) {
|
||||
// Non-critical, keep demo values
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────
|
||||
// ---- Helper: update tab badge counts ----
|
||||
function updateBadgeCount(tabName, count) {
|
||||
const btn = document.querySelector(`.tab-btn[onclick*="'${tabName}'"]`);
|
||||
if (!btn) return;
|
||||
const badge = btn.querySelector('.tab-btn__badge');
|
||||
if (badge) badge.textContent = count;
|
||||
}
|
||||
|
||||
// ---- Clock ----
|
||||
function startClock() {
|
||||
const el = document.getElementById('live-clock');
|
||||
if (!el) return;
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
el.textContent = now.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
function init() {
|
||||
initTabs();
|
||||
|
||||
// Set default period values
|
||||
const now = new Date();
|
||||
['tb-year', 'is-year', 'cp-year'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = now.getFullYear();
|
||||
});
|
||||
['tb-month', 'is-month', 'cp-month'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = now.getMonth() + 1;
|
||||
});
|
||||
const bsDate = document.getElementById('bs-date');
|
||||
if (bsDate) bsDate.value = now.toISOString().slice(0, 10);
|
||||
|
||||
// Set default entry date filters
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
document.getElementById('entries-from').value = firstDay;
|
||||
document.getElementById('entries-to').value = now.toISOString().slice(0, 10);
|
||||
|
||||
loadAccounts();
|
||||
if (!checkAuth()) return;
|
||||
startClock();
|
||||
loadSummaryCards();
|
||||
// Load initial tab data (cxc is active by default)
|
||||
loadAging();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// Public API
|
||||
// Expose switchTab globally for onclick handlers in HTML
|
||||
window.switchTab = switchTab;
|
||||
|
||||
return {
|
||||
loadAccounts, loadEntries, loadTrialBalance, loadIncomeStatement,
|
||||
loadBalanceSheet, loadAging, loadPeriods, closePeriod,
|
||||
showNewAccountModal, createAccount,
|
||||
showNewEntryModal, addEntryLine, updateEntryBalance, createEntry,
|
||||
showEntryDetail, closeModal,
|
||||
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
||||
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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})">‹</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})">›</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()">×</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()">×</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()">×</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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,90 +1,133 @@
|
||||
// /home/Autopartes/pos/static/js/inventory.js
|
||||
// Inventory management UI: CRUD, purchases, adjustments, transfers, physical count, alerts
|
||||
// Inventory management UI — rewritten to match design-system HTML structure
|
||||
// Panels: panel-stock, panel-entradas, panel-salidas, panel-traspasos, panel-ajustes, panel-conteos, panel-alertas
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API = '/pos/api/inventory';
|
||||
const token = localStorage.getItem('pos_token');
|
||||
var API = '/pos/api/inventory';
|
||||
var token = localStorage.getItem('pos_token');
|
||||
if (!token) { window.location.href = '/pos/login'; return; }
|
||||
|
||||
const headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
let currentPage = 1;
|
||||
let currentSearch = '';
|
||||
let draftCountId = null;
|
||||
var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
var currentPage = 1;
|
||||
var currentSearch = '';
|
||||
var draftCountId = null;
|
||||
|
||||
// --- API helper ---
|
||||
async function apiFetch(url, opts) {
|
||||
const resp = await fetch(url, Object.assign({ headers: headers }, opts || {}));
|
||||
if (resp.status === 401) { localStorage.removeItem('pos_token'); window.location.href = '/pos/login'; return null; }
|
||||
return resp.json();
|
||||
function apiFetch(url, opts) {
|
||||
return fetch(url, Object.assign({ headers: headers }, opts || {}))
|
||||
.then(function (resp) {
|
||||
if (resp.status === 401) {
|
||||
localStorage.removeItem('pos_token');
|
||||
window.location.href = '/pos/login';
|
||||
return null;
|
||||
}
|
||||
return resp.json();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tab switching ---
|
||||
document.querySelectorAll('.tab').forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
document.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function (c) { c.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
||||
// --- Helpers ---
|
||||
function fmt(n) { return (parseFloat(n) || 0).toFixed(2); }
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
if (tab.dataset.tab === 'alerts') loadAlerts();
|
||||
});
|
||||
});
|
||||
// =====================================================================
|
||||
// TAB SWITCHING — uses design-system switchTab() already in the HTML.
|
||||
// We hook into it to trigger data loads when tabs are activated.
|
||||
// =====================================================================
|
||||
|
||||
// --- Products ---
|
||||
async function loadItems(page, search) {
|
||||
var _origSwitchTab = window.switchTab;
|
||||
window.switchTab = function (name) {
|
||||
if (typeof _origSwitchTab === 'function') _origSwitchTab(name);
|
||||
if (name === 'alertas') loadAlerts();
|
||||
if (name === 'stock') loadItems(currentPage);
|
||||
};
|
||||
|
||||
// =====================================================================
|
||||
// STOCK / PRODUCTS (panel-stock)
|
||||
// =====================================================================
|
||||
|
||||
function loadItems(page, search) {
|
||||
currentPage = page || 1;
|
||||
currentSearch = search !== undefined ? search : currentSearch;
|
||||
var params = new URLSearchParams({ page: currentPage, per_page: 50 });
|
||||
if (currentSearch) params.set('q', currentSearch);
|
||||
|
||||
var data = await apiFetch(API + '/items?' + params.toString());
|
||||
if (!data) return;
|
||||
apiFetch(API + '/items?' + params.toString()).then(function (data) {
|
||||
if (!data) return;
|
||||
|
||||
var tbody = document.getElementById('productTableBody');
|
||||
var items = data.data || [];
|
||||
if (!items.length) { tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#999;">Sin productos</td></tr>'; return; }
|
||||
var tbody = document.getElementById('productTableBody');
|
||||
var items = data.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:30px;color:var(--color-text-muted);">Sin productos</td></tr>';
|
||||
document.getElementById('productPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = items.map(function (it) {
|
||||
return '<tr>' +
|
||||
'<td style="font-family:monospace;font-size:0.8rem;">' + esc(it.barcode) + '</td>' +
|
||||
'<td>' + esc(it.part_number) + '</td>' +
|
||||
'<td><strong>' + esc(it.name) + '</strong></td>' +
|
||||
'<td>' + esc(it.brand) + '</td>' +
|
||||
'<td style="font-weight:600;">' + it.stock + '</td>' +
|
||||
'<td>$' + fmt(it.cost) + '</td>' +
|
||||
'<td>$' + fmt(it.price_1) + '</td>' +
|
||||
'<td>$' + fmt(it.price_2) + '</td>' +
|
||||
'<td>$' + fmt(it.price_3) + '</td>' +
|
||||
'<td>' + esc(it.location) + '</td>' +
|
||||
'<td><button class="btn btn-secondary" onclick="viewHistory(' + it.id + ')" style="padding:4px 8px;font-size:0.75rem;">Historial</button> ' +
|
||||
'<button class="btn btn-secondary" onclick="printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')" style="padding:4px 8px;font-size:0.75rem;">Etiqueta</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
tbody.innerHTML = items.map(function (it) {
|
||||
return '<tr>' +
|
||||
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
|
||||
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
|
||||
'<td class="td--primary">' + esc(it.name) + '</td>' +
|
||||
'<td>' + esc(it.brand) + '</td>' +
|
||||
'<td style="text-align:right" class="td--primary">' + it.stock + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(it.cost) + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_1) + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_2) + '</td>' +
|
||||
'<td style="text-align:right" class="td--amount">$' + fmt(it.price_3) + '</td>' +
|
||||
'<td>' + esc(it.location) + '</td>' +
|
||||
'<td>' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="viewHistory(' + it.id + ')">Historial</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button>' +
|
||||
'</td></tr>';
|
||||
}).join('');
|
||||
|
||||
// Pagination
|
||||
var pg = data.pagination || {};
|
||||
var pgEl = document.getElementById('productPagination');
|
||||
if (pg.total_pages > 1) {
|
||||
pgEl.innerHTML = '<button class="btn btn-secondary" ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadItems(' + (pg.page - 1) + ')"') + '>«</button>' +
|
||||
'<span style="padding:6px 12px;font-size:0.85rem;">' + pg.page + ' / ' + pg.total_pages + ' (' + pg.total + ' items)</span>' +
|
||||
'<button class="btn btn-secondary" ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadItems(' + (pg.page + 1) + ')"') + '>»</button>';
|
||||
} else {
|
||||
pgEl.innerHTML = '<span style="font-size:0.85rem;color:#999;">' + (pg.total || 0) + ' productos</span>';
|
||||
}
|
||||
// Pagination
|
||||
var pg = data.pagination || {};
|
||||
var pgEl = document.getElementById('productPagination');
|
||||
if (pg.total_pages > 1) {
|
||||
pgEl.innerHTML =
|
||||
'<div class="pagination">' +
|
||||
'<button class="page-btn" ' + (pg.page <= 1 ? 'disabled' : 'onclick="window._loadItems(' + (pg.page - 1) + ')"') + '>‹</button>' +
|
||||
'<span style="padding:0 var(--space-2);font-size:var(--text-body-sm);color:var(--color-text-muted);">' + pg.page + ' / ' + pg.total_pages + ' (' + pg.total + ' productos)</span>' +
|
||||
'<button class="page-btn" ' + (pg.page >= pg.total_pages ? 'disabled' : 'onclick="window._loadItems(' + (pg.page + 1) + ')"') + '>›</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search
|
||||
var searchInput = document.getElementById('productSearch');
|
||||
var searchTimeout;
|
||||
searchInput.addEventListener('input', function () {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function () { loadItems(1, searchInput.value.trim()); }, 350);
|
||||
});
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function () {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function () {
|
||||
loadItems(1, searchInput.value.trim());
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create item ---
|
||||
async function createItem() {
|
||||
// =====================================================================
|
||||
// CREATE ITEM (createModal)
|
||||
// =====================================================================
|
||||
|
||||
function showCreateModal() {
|
||||
document.getElementById('createModal').classList.add('is-open');
|
||||
}
|
||||
function closeCreateModal() {
|
||||
document.getElementById('createModal').classList.remove('is-open');
|
||||
document.getElementById('createResult').innerHTML = '';
|
||||
}
|
||||
|
||||
function createItem() {
|
||||
var data = {
|
||||
part_number: document.getElementById('newPartNumber').value.trim(),
|
||||
name: document.getElementById('newName').value.trim(),
|
||||
@@ -98,19 +141,33 @@
|
||||
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
|
||||
location: document.getElementById('newLocation').value.trim()
|
||||
};
|
||||
if (!data.part_number || !data.name) { document.getElementById('createResult').innerHTML = '<span style="color:red;">Numero de parte y nombre son obligatorios</span>'; return; }
|
||||
|
||||
var result = await apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) });
|
||||
if (result && result.id) {
|
||||
document.getElementById('createResult').innerHTML = '<span style="color:green;">Creado ID ' + result.id + ' | Barcode: ' + result.barcode + '</span>';
|
||||
loadItems(currentPage);
|
||||
} else {
|
||||
document.getElementById('createResult').innerHTML = '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
if (!data.part_number || !data.name) {
|
||||
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
|
||||
return;
|
||||
}
|
||||
apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||
if (result && result.id) {
|
||||
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">Creado ID ' + result.id + ' | Barcode: ' + result.barcode + '</span>';
|
||||
loadItems(currentPage);
|
||||
} else {
|
||||
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Purchase ---
|
||||
async function recordPurchase() {
|
||||
// =====================================================================
|
||||
// PURCHASE / ENTRADA (purchaseModal)
|
||||
// =====================================================================
|
||||
|
||||
function showPurchaseModal() {
|
||||
document.getElementById('purchaseModal').classList.add('is-open');
|
||||
}
|
||||
function closePurchaseModal() {
|
||||
document.getElementById('purchaseModal').classList.remove('is-open');
|
||||
document.getElementById('purchaseResult').innerHTML = '';
|
||||
}
|
||||
|
||||
function recordPurchase() {
|
||||
var data = {
|
||||
inventory_id: parseInt(document.getElementById('purchaseItemId').value),
|
||||
quantity: parseInt(document.getElementById('purchaseQty').value),
|
||||
@@ -119,32 +176,58 @@
|
||||
notes: document.getElementById('purchaseNotes').value.trim()
|
||||
};
|
||||
if (!data.inventory_id || !data.quantity || !data.unit_cost) {
|
||||
document.getElementById('purchaseResult').innerHTML = '<span style="color:red;">Complete todos los campos</span>'; return;
|
||||
document.getElementById('purchaseResult').innerHTML = '<span style="color:var(--color-error);">Complete todos los campos obligatorios</span>';
|
||||
return;
|
||||
}
|
||||
var result = await apiFetch(API + '/purchase', { method: 'POST', body: JSON.stringify(data) });
|
||||
document.getElementById('purchaseResult').innerHTML = result && result.operation_id
|
||||
? '<span style="color:green;">Compra registrada (op #' + result.operation_id + ')</span>'
|
||||
: '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
apiFetch(API + '/purchase', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||
document.getElementById('purchaseResult').innerHTML = result && result.operation_id
|
||||
? '<span style="color:var(--color-success);">Compra registrada (op #' + result.operation_id + ')</span>'
|
||||
: '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// --- Adjustment ---
|
||||
async function recordAdjustment() {
|
||||
// =====================================================================
|
||||
// ADJUSTMENT / AJUSTE (adjustmentModal)
|
||||
// =====================================================================
|
||||
|
||||
function showAdjustmentModal() {
|
||||
document.getElementById('adjustmentModal').classList.add('is-open');
|
||||
}
|
||||
function closeAdjustmentModal() {
|
||||
document.getElementById('adjustmentModal').classList.remove('is-open');
|
||||
document.getElementById('adjustResult').innerHTML = '';
|
||||
}
|
||||
|
||||
function recordAdjustment() {
|
||||
var data = {
|
||||
inventory_id: parseInt(document.getElementById('adjustItemId').value),
|
||||
quantity: parseInt(document.getElementById('adjustQty').value),
|
||||
reason: document.getElementById('adjustReason').value.trim()
|
||||
};
|
||||
if (!data.inventory_id || data.quantity === undefined || !data.reason) {
|
||||
document.getElementById('adjustResult').innerHTML = '<span style="color:red;">Complete todos los campos (razon obligatoria)</span>'; return;
|
||||
document.getElementById('adjustResult').innerHTML = '<span style="color:var(--color-error);">Complete todos los campos (razon obligatoria)</span>';
|
||||
return;
|
||||
}
|
||||
var result = await apiFetch(API + '/adjustment', { method: 'POST', body: JSON.stringify(data) });
|
||||
document.getElementById('adjustResult').innerHTML = result && result.operation_id
|
||||
? '<span style="color:green;">Ajuste registrado (op #' + result.operation_id + ')</span>'
|
||||
: '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
apiFetch(API + '/adjustment', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||
document.getElementById('adjustResult').innerHTML = result && result.operation_id
|
||||
? '<span style="color:var(--color-success);">Ajuste registrado (op #' + result.operation_id + ')</span>'
|
||||
: '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// --- Transfer ---
|
||||
async function recordTransfer() {
|
||||
// =====================================================================
|
||||
// TRANSFER / TRASPASO (transferModal)
|
||||
// =====================================================================
|
||||
|
||||
function showTransferModal() {
|
||||
document.getElementById('transferModal').classList.add('is-open');
|
||||
}
|
||||
function closeTransferModal() {
|
||||
document.getElementById('transferModal').classList.remove('is-open');
|
||||
document.getElementById('transferResult').innerHTML = '';
|
||||
}
|
||||
|
||||
function recordTransfer() {
|
||||
var data = {
|
||||
inventory_id: parseInt(document.getElementById('transferItemId').value),
|
||||
from_branch_id: parseInt(document.getElementById('transferFrom').value),
|
||||
@@ -153,27 +236,44 @@
|
||||
notes: document.getElementById('transferNotes').value.trim()
|
||||
};
|
||||
if (!data.inventory_id || !data.from_branch_id || !data.to_branch_id || !data.quantity) {
|
||||
document.getElementById('transferResult').innerHTML = '<span style="color:red;">Complete todos los campos</span>'; return;
|
||||
document.getElementById('transferResult').innerHTML = '<span style="color:var(--color-error);">Complete todos los campos</span>';
|
||||
return;
|
||||
}
|
||||
var result = await apiFetch(API + '/transfer', { method: 'POST', body: JSON.stringify(data) });
|
||||
document.getElementById('transferResult').innerHTML = result && result.out_operation_id
|
||||
? '<span style="color:green;">Transferencia registrada</span>'
|
||||
: '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
apiFetch(API + '/transfer', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
|
||||
document.getElementById('transferResult').innerHTML = result && result.out_operation_id
|
||||
? '<span style="color:var(--color-success);">Transferencia registrada</span>'
|
||||
: '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// PHYSICAL COUNT / CONTEO (countModal)
|
||||
// =====================================================================
|
||||
|
||||
function showCountModal() {
|
||||
document.getElementById('countModal').classList.add('is-open');
|
||||
// Pre-add one line if empty
|
||||
if (!document.querySelectorAll('#countLines .count-row').length) {
|
||||
addCountLine();
|
||||
}
|
||||
}
|
||||
function closeCountModal() {
|
||||
document.getElementById('countModal').classList.remove('is-open');
|
||||
}
|
||||
|
||||
// --- Physical Count (two-phase) ---
|
||||
function addCountLine() {
|
||||
var container = document.getElementById('countLines');
|
||||
var row = document.createElement('div');
|
||||
row.className = 'count-row';
|
||||
row.innerHTML = '<input type="number" placeholder="ID producto" class="count-inv-id" style="width:120px;">' +
|
||||
'<input type="number" placeholder="Cantidad contada" class="count-qty" style="width:140px;">' +
|
||||
'<button class="btn btn-secondary" onclick="this.parentElement.remove()">Quitar</button>';
|
||||
row.innerHTML =
|
||||
'<input type="number" placeholder="ID producto" class="count-inv-id" style="width:140px;" />' +
|
||||
'<input type="number" placeholder="Cantidad contada" class="count-qty" style="width:160px;" />' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="this.parentElement.remove()">Quitar</button>';
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
async function startPhysicalCount() {
|
||||
var rows = document.querySelectorAll('.count-row');
|
||||
function startPhysicalCount() {
|
||||
var rows = document.querySelectorAll('#countLines .count-row');
|
||||
var items = [];
|
||||
rows.forEach(function (row) {
|
||||
var invId = parseInt(row.querySelector('.count-inv-id').value);
|
||||
@@ -182,85 +282,152 @@
|
||||
});
|
||||
if (!items.length) { alert('Agregue al menos una linea'); return; }
|
||||
|
||||
var result = await apiFetch(API + '/physical-count/start', { method: 'POST', body: JSON.stringify({ items: items }) });
|
||||
if (!result || !result.count_id) {
|
||||
document.getElementById('countResults').innerHTML = '<span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
return;
|
||||
}
|
||||
apiFetch(API + '/physical-count/start', { method: 'POST', body: JSON.stringify({ items: items }) }).then(function (result) {
|
||||
if (!result || !result.count_id) {
|
||||
document.getElementById('countResults').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
draftCountId = result.count_id;
|
||||
var html = '<h4>Borrador #' + result.count_id + ' — ' + result.message + '</h4>';
|
||||
html += '<table class="inv-table"><thead><tr><th>ID</th><th>Esperado</th><th>Contado</th><th>Diferencia</th></tr></thead><tbody>';
|
||||
(result.results || []).forEach(function (r) {
|
||||
var color = r.difference === 0 ? '#16a34a' : (r.difference < 0 ? '#dc2626' : '#ca8a04');
|
||||
html += '<tr><td>' + r.inventory_id + '</td><td>' + r.expected + '</td><td>' + r.counted + '</td><td style="color:' + color + ';font-weight:600;">' + (r.difference > 0 ? '+' : '') + r.difference + '</td></tr>';
|
||||
draftCountId = result.count_id;
|
||||
var html = '<h4 style="margin-bottom:var(--space-3);">Borrador #' + result.count_id + ' — ' + esc(result.message) + '</h4>';
|
||||
html += '<table class="data-table"><thead><tr><th>ID</th><th>Esperado</th><th>Contado</th><th>Diferencia</th></tr></thead><tbody>';
|
||||
(result.results || []).forEach(function (r) {
|
||||
var color = r.difference === 0 ? 'var(--color-success)' : (r.difference < 0 ? 'var(--color-error)' : 'var(--color-warning)');
|
||||
html += '<tr><td>' + r.inventory_id + '</td><td>' + r.expected + '</td><td>' + r.counted + '</td><td style="color:' + color + ';font-weight:600;">' + (r.difference > 0 ? '+' : '') + r.difference + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
html += '<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">';
|
||||
html += '<button class="btn btn--primary btn--sm" onclick="approvePhysicalCount()">Aprobar y aplicar ajustes</button>';
|
||||
html += '<button class="btn btn--ghost btn--sm" onclick="cancelDraft()">Cancelar borrador</button>';
|
||||
html += '</div>';
|
||||
document.getElementById('countResults').innerHTML = html;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
html += '<button class="btn btn-primary" onclick="approvePhysicalCount()" style="margin-top:12px;">Aprobar y aplicar ajustes</button>';
|
||||
html += ' <button class="btn btn-secondary" onclick="cancelDraft()" style="margin-top:12px;">Cancelar</button>';
|
||||
document.getElementById('countResults').innerHTML = html;
|
||||
}
|
||||
|
||||
async function approvePhysicalCount() {
|
||||
function approvePhysicalCount() {
|
||||
if (!draftCountId) { alert('No hay borrador activo'); return; }
|
||||
var result = await apiFetch(API + '/physical-count/approve', { method: 'POST', body: JSON.stringify({ count_id: draftCountId }) });
|
||||
if (result && result.status === 'approved') {
|
||||
document.getElementById('countResults').innerHTML = '<span style="color:green;">' + result.message + '</span>';
|
||||
draftCountId = null;
|
||||
} else {
|
||||
document.getElementById('countResults').innerHTML += '<br><span style="color:red;">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
}
|
||||
apiFetch(API + '/physical-count/approve', { method: 'POST', body: JSON.stringify({ count_id: draftCountId }) }).then(function (result) {
|
||||
if (result && result.status === 'approved') {
|
||||
document.getElementById('countResults').innerHTML = '<span style="color:var(--color-success);">' + esc(result.message) + '</span>';
|
||||
draftCountId = null;
|
||||
} else {
|
||||
document.getElementById('countResults').innerHTML += '<br><span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cancelDraft() {
|
||||
draftCountId = null;
|
||||
document.getElementById('countResults').innerHTML = '<span style="color:#999;">Borrador cancelado</span>';
|
||||
document.getElementById('countResults').innerHTML = '<span style="color:var(--color-text-muted);">Borrador cancelado</span>';
|
||||
}
|
||||
|
||||
// --- Alerts ---
|
||||
async function loadAlerts() {
|
||||
var data = await apiFetch(API + '/alerts');
|
||||
if (!data) return;
|
||||
var el = document.getElementById('alertsList');
|
||||
var alerts = data.data || [];
|
||||
if (!alerts.length) { el.innerHTML = '<p style="color:#999;">Sin alertas activas</p>'; return; }
|
||||
// =====================================================================
|
||||
// ALERTS (panel-alertas)
|
||||
// =====================================================================
|
||||
|
||||
el.innerHTML = alerts.map(function (a) {
|
||||
var cls = a.severity === 'critical' ? 'alert-critical' : (a.severity === 'warning' ? 'alert-warning' : 'alert-info');
|
||||
var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : 'EXCESO');
|
||||
return '<div class="alert-card ' + cls + '">' +
|
||||
'<div><strong>[' + icon + ']</strong> ' + esc(a.part_number) + ' — ' + esc(a.name) + ' | Stock: ' + a.stock +
|
||||
(a.min_stock ? ' (min: ' + a.min_stock + ')' : '') + (a.max_stock ? ' (max: ' + a.max_stock + ')' : '') + '</div>' +
|
||||
'<span style="font-size:0.8rem;color:#888;">Sucursal ' + a.branch_id + '</span></div>';
|
||||
}).join('');
|
||||
function loadAlerts() {
|
||||
apiFetch(API + '/alerts').then(function (data) {
|
||||
if (!data) return;
|
||||
var alerts = data.data || [];
|
||||
var container = document.getElementById('alertsContent');
|
||||
if (!container) return;
|
||||
|
||||
if (!alerts.length) {
|
||||
container.innerHTML = '<p style="padding:var(--space-6);text-align:center;color:var(--color-text-muted);">Sin alertas activas</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
|
||||
// Group by severity
|
||||
var critical = alerts.filter(function (a) { return a.severity === 'critical'; });
|
||||
var warning = alerts.filter(function (a) { return a.severity === 'warning'; });
|
||||
var info = alerts.filter(function (a) { return a.severity !== 'critical' && a.severity !== 'warning'; });
|
||||
|
||||
if (critical.length) {
|
||||
html += '<div class="section-heading"><span class="section-heading__title">Criticas</span><div class="section-heading__line"></div><span class="badge badge--low">' + critical.length + '</span></div>';
|
||||
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
||||
critical.forEach(function (a) {
|
||||
var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : a.type.toUpperCase());
|
||||
html += buildAlertCard(a, icon, 'critical');
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (warning.length) {
|
||||
html += '<div class="section-heading"><span class="section-heading__title">Advertencias</span><div class="section-heading__line"></div><span class="badge badge--over">' + warning.length + '</span></div>';
|
||||
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
||||
warning.forEach(function (a) {
|
||||
html += buildAlertCard(a, 'EXCESO', 'warning');
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (info.length) {
|
||||
html += '<div class="section-heading"><span class="section-heading__title">Informativas</span><div class="section-heading__line"></div><span class="badge badge--ok">' + info.length + '</span></div>';
|
||||
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
||||
info.forEach(function (a) {
|
||||
html += buildAlertCard(a, 'INFO', 'info');
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
// --- History modal ---
|
||||
async function viewHistory(itemId) {
|
||||
var data = await apiFetch(API + '/items/' + itemId + '/history');
|
||||
if (!data) return;
|
||||
var history = data.data || [];
|
||||
var html = '';
|
||||
if (!history.length) { html = '<p style="color:#999;">Sin movimientos</p>'; }
|
||||
else {
|
||||
html = '<table class="inv-table"><thead><tr><th>Fecha</th><th>Tipo</th><th>Cantidad</th><th>Costo</th><th>Empleado</th><th>Notas</th></tr></thead><tbody>';
|
||||
history.forEach(function (h) {
|
||||
var qtyColor = h.quantity > 0 ? '#16a34a' : '#dc2626';
|
||||
html += '<tr><td style="font-size:0.8rem;">' + h.date + '</td><td>' + h.type + '</td><td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td><td>' + (h.cost ? '$' + fmt(h.cost) : '-') + '</td><td>' + esc(h.employee) + '</td><td style="font-size:0.8rem;">' + esc(h.notes) + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
document.getElementById('historyContent').innerHTML = html;
|
||||
document.getElementById('historyModal').classList.add('show');
|
||||
function buildAlertCard(a, icon, level) {
|
||||
var cls = level === 'critical' ? 'alert-card--critical' : (level === 'warning' ? 'alert-card--warning' : 'alert-card--info');
|
||||
return '<div class="alert-card ' + cls + '">' +
|
||||
'<div class="alert-card__icon"><svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>' +
|
||||
'<div class="alert-card__body">' +
|
||||
'<div class="alert-card__title">[' + icon + '] ' + esc(a.part_number) + ' — ' + esc(a.name) + '</div>' +
|
||||
'<div class="alert-card__desc">Stock: ' + a.stock +
|
||||
(a.min_stock ? ' (min: ' + a.min_stock + ')' : '') +
|
||||
(a.max_stock ? ' (max: ' + a.max_stock + ')' : '') +
|
||||
' · Sucursal ' + a.branch_id + '</div>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function closeHistoryModal() { document.getElementById('historyModal').classList.remove('show'); }
|
||||
// =====================================================================
|
||||
// HISTORY MODAL
|
||||
// =====================================================================
|
||||
|
||||
// --- Create modal ---
|
||||
function showCreateModal() { document.getElementById('createModal').classList.add('show'); }
|
||||
function closeCreateModal() { document.getElementById('createModal').classList.remove('show'); }
|
||||
function viewHistory(itemId) {
|
||||
apiFetch(API + '/items/' + itemId + '/history').then(function (data) {
|
||||
if (!data) return;
|
||||
var history = data.data || [];
|
||||
var html = '';
|
||||
if (!history.length) {
|
||||
html = '<p style="color:var(--color-text-muted);text-align:center;padding:var(--space-4);">Sin movimientos</p>';
|
||||
} else {
|
||||
html = '<table class="data-table"><thead><tr><th>Fecha</th><th>Tipo</th><th>Cantidad</th><th>Costo</th><th>Empleado</th><th>Notas</th></tr></thead><tbody>';
|
||||
history.forEach(function (h) {
|
||||
var qtyColor = h.quantity > 0 ? 'var(--color-success)' : 'var(--color-error)';
|
||||
html += '<tr>' +
|
||||
'<td style="font-size:var(--text-caption);">' + esc(h.date) + '</td>' +
|
||||
'<td>' + esc(h.type) + '</td>' +
|
||||
'<td style="color:' + qtyColor + ';font-weight:600;">' + (h.quantity > 0 ? '+' : '') + h.quantity + '</td>' +
|
||||
'<td class="td--amount">' + (h.cost ? '$' + fmt(h.cost) : '—') + '</td>' +
|
||||
'<td>' + esc(h.employee) + '</td>' +
|
||||
'<td style="font-size:var(--text-caption);">' + esc(h.notes) + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
document.getElementById('historyContent').innerHTML = html;
|
||||
document.getElementById('historyModal').classList.add('is-open');
|
||||
});
|
||||
}
|
||||
|
||||
function closeHistoryModal() {
|
||||
document.getElementById('historyModal').classList.remove('is-open');
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// BARCODE LABEL PRINT
|
||||
// =====================================================================
|
||||
|
||||
// --- Barcode label ---
|
||||
function printBarcode(barcode, partNumber, name) {
|
||||
var w = window.open('', '_blank', 'width=400,height=250');
|
||||
w.document.write('<html><head><title>Etiqueta</title><style>body{font-family:monospace;text-align:center;padding:20px;}h1{font-size:1.5rem;margin:8px 0;}p{margin:4px 0;}</style></head><body>');
|
||||
@@ -272,20 +439,27 @@
|
||||
w.print();
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
function fmt(n) { return (parseFloat(n) || 0).toFixed(2); }
|
||||
function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
// =====================================================================
|
||||
// EXPOSE GLOBALS (for onclick handlers in HTML)
|
||||
// =====================================================================
|
||||
|
||||
// --- Expose globals ---
|
||||
window._loadItems = function (p) { loadItems(p); };
|
||||
window.viewHistory = viewHistory;
|
||||
window.closeHistoryModal = closeHistoryModal;
|
||||
window.showCreateModal = showCreateModal;
|
||||
window.closeCreateModal = closeCreateModal;
|
||||
window.createItem = createItem;
|
||||
window.showPurchaseModal = showPurchaseModal;
|
||||
window.closePurchaseModal = closePurchaseModal;
|
||||
window.recordPurchase = recordPurchase;
|
||||
window.showAdjustmentModal = showAdjustmentModal;
|
||||
window.closeAdjustmentModal = closeAdjustmentModal;
|
||||
window.recordAdjustment = recordAdjustment;
|
||||
window.showTransferModal = showTransferModal;
|
||||
window.closeTransferModal = closeTransferModal;
|
||||
window.recordTransfer = recordTransfer;
|
||||
window.showCountModal = showCountModal;
|
||||
window.closeCountModal = closeCountModal;
|
||||
window.addCountLine = addCountLine;
|
||||
window.startPhysicalCount = startPhysicalCount;
|
||||
window.approvePhysicalCount = approvePhysicalCount;
|
||||
@@ -293,6 +467,9 @@
|
||||
window.loadAlerts = loadAlerts;
|
||||
window.printBarcode = printBarcode;
|
||||
|
||||
// --- Init ---
|
||||
// =====================================================================
|
||||
// INIT — load stock on page load
|
||||
// =====================================================================
|
||||
|
||||
loadItems(1);
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// /home/Autopartes/pos/static/js/invoicing.js
|
||||
// Invoicing module: CFDI queue management, cancel, PDF
|
||||
// Invoicing module — wired to design-system HTML IDs
|
||||
// Tabs: panel-facturas, panel-notas, panel-complementos, panel-cancelaciones, panel-config
|
||||
// Modals: modalDetalleOverlay, modalCancelOverlay
|
||||
|
||||
const Invoicing = (() => {
|
||||
const API = '/pos/api/invoicing';
|
||||
@@ -25,128 +27,304 @@ const Invoicing = (() => {
|
||||
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function badgeClass(status) {
|
||||
return {
|
||||
pending: 'badge-pending',
|
||||
sending: 'badge-sending',
|
||||
stamped: 'badge-stamped',
|
||||
failed: 'badge-failed',
|
||||
cancelled: 'badge-cancelled',
|
||||
}[status] || '';
|
||||
// ---- Auth check ----
|
||||
function checkAuth() {
|
||||
if (!token()) {
|
||||
window.location.href = '/pos/login';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function badgeLabel(status) {
|
||||
return {
|
||||
pending: 'Pendiente',
|
||||
sending: 'Enviando',
|
||||
stamped: 'Timbrado',
|
||||
failed: 'Fallido',
|
||||
cancelled: 'Cancelado',
|
||||
}[status] || status;
|
||||
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
|
||||
function switchTab(name) {
|
||||
// Deactivate all tabs
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('is-active');
|
||||
btn.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('is-active'));
|
||||
|
||||
// Activate the clicked tab button
|
||||
const tabBtn = document.querySelector(`.tab-btn[onclick*="'${name}'"]`) ||
|
||||
document.getElementById(`tab-${name}`);
|
||||
if (tabBtn) {
|
||||
tabBtn.classList.add('is-active');
|
||||
tabBtn.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
|
||||
// Activate the target panel
|
||||
const panel = document.getElementById(`panel-${name}`);
|
||||
if (panel) panel.classList.add('is-active');
|
||||
|
||||
// Load data for the activated tab
|
||||
if (name === 'facturas') loadFacturas();
|
||||
if (name === 'notas') loadNotas();
|
||||
if (name === 'complementos') loadComplementos();
|
||||
if (name === 'cancelaciones') loadCancelaciones();
|
||||
}
|
||||
|
||||
// ─── Queue List ────────────────────────────────
|
||||
// ---- Badge helpers ----
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
pending: { css: 'badge--pendiente', label: 'Pendiente' },
|
||||
pendiente: { css: 'badge--pendiente', label: 'Pendiente' },
|
||||
sending: { css: 'badge--proceso', label: 'Enviando' },
|
||||
stamped: { css: 'badge--timbrada', label: 'Timbrada' },
|
||||
timbrada: { css: 'badge--timbrada', label: 'Timbrada' },
|
||||
failed: { css: 'badge--rechazada', label: 'Fallido' },
|
||||
cancelled: { css: 'badge--cancelada', label: 'Cancelada' },
|
||||
cancelada: { css: 'badge--cancelada', label: 'Cancelada' },
|
||||
ppd: { css: 'badge--ppd', label: 'PPD' },
|
||||
proceso: { css: 'badge--proceso', label: 'En proceso' },
|
||||
aceptada: { css: 'badge--aceptada', label: 'Aceptada SAT' },
|
||||
rechazada: { css: 'badge--rechazada', label: 'Rechazada SAT' },
|
||||
};
|
||||
const s = map[status] || { css: '', label: status || '' };
|
||||
return `<span class="badge ${s.css}">${s.label}</span>`;
|
||||
}
|
||||
|
||||
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
|
||||
async function loadFacturas() {
|
||||
const panel = document.getElementById('panel-facturas');
|
||||
if (!panel) return;
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
async function loadQueue() {
|
||||
try {
|
||||
const status = document.getElementById('filter-status').value;
|
||||
const type = document.getElementById('filter-type').value;
|
||||
let qs = '?per_page=50';
|
||||
if (status) qs += `&status=${status}`;
|
||||
if (type) qs += `&type=${type}`;
|
||||
|
||||
const res = await api(`/queue${qs}`);
|
||||
renderQueue(res.data || []);
|
||||
updateStats(res.data || []);
|
||||
} catch (e) {
|
||||
document.getElementById('queue-list').innerHTML =
|
||||
`<p style="color:var(--danger);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(items) {
|
||||
const counts = { pending: 0, sending: 0, stamped: 0, failed: 0, cancelled: 0 };
|
||||
items.forEach(i => { if (counts[i.status] !== undefined) counts[i.status]++; });
|
||||
|
||||
document.getElementById('queue-stats').innerHTML = `
|
||||
<div class="stat-card"><div class="number">${counts.pending}</div><div class="label">Pendientes</div></div>
|
||||
<div class="stat-card"><div class="number">${counts.sending}</div><div class="label">Enviando</div></div>
|
||||
<div class="stat-card"><div class="number">${counts.stamped}</div><div class="label">Timbrados</div></div>
|
||||
<div class="stat-card"><div class="number">${counts.failed}</div><div class="label">Fallidos</div></div>
|
||||
<div class="stat-card"><div class="number">${counts.cancelled}</div><div class="label">Cancelados</div></div>`;
|
||||
}
|
||||
|
||||
function renderQueue(items) {
|
||||
const container = document.getElementById('queue-list');
|
||||
if (!items.length) { container.innerHTML = '<p>No hay CFDIs en la cola.</p>'; return; }
|
||||
|
||||
let html = `<table class="queue-table">
|
||||
<thead><tr>
|
||||
<th>#</th><th>Venta</th><th>Tipo</th><th>Folio</th>
|
||||
<th>UUID</th><th>Estado</th><th>Reintentos</th><th>Fecha</th><th>Acciones</th>
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
for (const item of items) {
|
||||
const uuid = item.uuid_fiscal
|
||||
? `${item.uuid_fiscal.substring(0, 8)}...`
|
||||
: '-';
|
||||
html += `<tr>
|
||||
<td>${item.id}</td>
|
||||
<td>#${item.sale_id}</td>
|
||||
<td>${item.type}</td>
|
||||
<td>${item.provisional_folio || '-'}</td>
|
||||
<td title="${item.uuid_fiscal || ''}">${uuid}</td>
|
||||
<td><span class="badge ${badgeClass(item.status)}">${badgeLabel(item.status)}</span></td>
|
||||
<td>${item.retry_count || 0}</td>
|
||||
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : ''}</td>
|
||||
const res = await api('/queue?per_page=50&type=Ingreso');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay facturas en este periodo.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
|
||||
<td class="td--primary">${item.serie || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td class="td--mono">${item.rfc || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.subtotal)}</td>
|
||||
<td class="td--amount">$${fmt(item.tax)}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<button class="btn btn-secondary" style="padding:0.2rem 0.4rem;font-size:0.8rem;"
|
||||
onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.status === 'stamped' ? `<button class="btn btn-danger" style="padding:0.2rem 0.4rem;font-size:0.8rem;"
|
||||
onclick="Invoicing.showCancelModal(${item.id})">Cancelar</button>` : ''}
|
||||
${item.sale_id ? `<a href="/pos/api/invoicing/${item.sale_id}/pdf" target="_blank"
|
||||
class="btn btn-secondary" style="padding:0.2rem 0.4rem;font-size:0.8rem;">PDF</a>` : ''}
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
</tr>`).join('');
|
||||
|
||||
// Update footer count
|
||||
const footer = panel.querySelector('.table-footer span');
|
||||
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ─── Detail ────────────────────────────────────
|
||||
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
|
||||
async function loadNotas() {
|
||||
const panel = document.getElementById('panel-notas');
|
||||
if (!panel) return;
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&type=Egreso');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
||||
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td>${item.description || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Complementos de Pago (Tab 3) — loads from CFDI queue with type=Pago ----
|
||||
async function loadComplementos() {
|
||||
const panel = document.getElementById('panel-complementos');
|
||||
if (!panel) return;
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&type=Pago');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay complementos de pago.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
||||
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
|
||||
async function loadCancelaciones() {
|
||||
const panel = document.getElementById('panel-cancelaciones');
|
||||
if (!panel) return;
|
||||
const grid = panel.querySelector('.cancel-grid');
|
||||
if (!grid) return;
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&status=cancelled');
|
||||
const items = res.data || [];
|
||||
|
||||
// Also try to get in-process cancellations
|
||||
let processingItems = [];
|
||||
try {
|
||||
const res2 = await api('/queue?per_page=50&status=cancelling');
|
||||
processingItems = res2.data || [];
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
const allItems = [...processingItems, ...items];
|
||||
if (!allItems.length) {
|
||||
grid.innerHTML = '<p style="padding:var(--space-6);color:var(--color-text-muted);text-align:center;">No hay solicitudes de cancelacion.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = allItems.map(item => {
|
||||
const cardClass = item.status === 'cancelled' ? 'cancel-card--aceptada' :
|
||||
item.status === 'cancelling' ? 'cancel-card--proceso' :
|
||||
item.cancel_accepted === false ? 'cancel-card--rechazada' :
|
||||
'cancel-card--proceso';
|
||||
const badgeText = item.status === 'cancelled' ? statusBadge('aceptada') :
|
||||
item.cancel_accepted === false ? statusBadge('rechazada') :
|
||||
statusBadge('proceso');
|
||||
|
||||
return `<div class="cancel-card ${cardClass}">
|
||||
<div class="cancel-card__header">
|
||||
<span class="cancel-card__folio">${item.provisional_folio || `CFDI-${item.id}`}</span>
|
||||
${badgeText}
|
||||
</div>
|
||||
<div class="cancel-card__body">
|
||||
<div class="cancel-card__row">
|
||||
<span class="cancel-card__row-label">Cliente</span>
|
||||
<span class="cancel-card__row-value">${item.customer_name || '-'}</span>
|
||||
</div>
|
||||
<div class="cancel-card__row">
|
||||
<span class="cancel-card__row-label">RFC</span>
|
||||
<span class="cancel-card__row-value" style="font-family:var(--font-mono);font-size:0.8rem;">${item.rfc || '-'}</span>
|
||||
</div>
|
||||
<div class="cancel-card__row">
|
||||
<span class="cancel-card__row-label">Motivo</span>
|
||||
<span class="cancel-card__row-value">${item.cancel_motive || '-'}</span>
|
||||
</div>
|
||||
<div class="cancel-card__row">
|
||||
<span class="cancel-card__row-label">Monto</span>
|
||||
<span class="cancel-card__row-value" style="font-family:var(--font-mono);font-weight:600;color:var(--color-text-primary);">$${fmt(item.total)} MXN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cancel-card__footer">
|
||||
<span style="font-size:var(--text-caption);color:var(--color-text-muted);">${item.cancelled_at ? 'Cancelada: ' + new Date(item.cancelled_at).toLocaleDateString('es-MX') : item.created_at ? 'Solicitada: ' + new Date(item.created_at).toLocaleDateString('es-MX') : ''}</span>
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver detalle</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<p style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Detail modal (uses modalDetalleOverlay) ----
|
||||
async function showDetail(cfdiId) {
|
||||
const overlay = document.getElementById('modalDetalleOverlay');
|
||||
if (!overlay) return;
|
||||
|
||||
try {
|
||||
const item = await api(`/queue/${cfdiId}`);
|
||||
let html = `<h3>CFDI #${item.id}</h3>
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item"><label>Venta</label><span>#${item.sale_id}</span></div>
|
||||
<div class="detail-item"><label>Tipo</label><span>${item.type}</span></div>
|
||||
<div class="detail-item"><label>Estado</label><span class="badge ${badgeClass(item.status)}">${badgeLabel(item.status)}</span></div>
|
||||
<div class="detail-item"><label>Folio Provisional</label><span>${item.provisional_folio || '-'}</span></div>
|
||||
<div class="detail-item"><label>UUID Fiscal</label><span>${item.uuid_fiscal || '-'}</span></div>
|
||||
<div class="detail-item"><label>Reintentos</label><span>${item.retry_count}</span></div>
|
||||
<div class="detail-item"><label>Creado</label><span>${item.created_at || '-'}</span></div>
|
||||
<div class="detail-item"><label>Timbrado</label><span>${item.stamped_at || '-'}</span></div>
|
||||
</div>`;
|
||||
const modalCard = overlay.querySelector('.modal-card');
|
||||
if (!modalCard) return;
|
||||
|
||||
if (item.error_message) {
|
||||
html += `<p style="color:var(--danger);"><strong>Error:</strong> ${item.error_message}</p>`;
|
||||
}
|
||||
if (item.cancel_motive) {
|
||||
html += `<p><strong>Motivo cancelacion:</strong> ${item.cancel_motive}</p>`;
|
||||
// Update header
|
||||
const headerTitle = modalCard.querySelector('div > div:first-child > div:first-child');
|
||||
const headerSub = modalCard.querySelector('div > div:first-child > div:nth-child(2)');
|
||||
if (headerTitle) headerTitle.textContent = 'Detalle de Factura';
|
||||
if (headerSub) headerSub.textContent = `${item.provisional_folio || 'CFDI-' + item.id} \u2014 ${item.status === 'stamped' ? 'Timbrada' : item.status === 'cancelled' ? 'Cancelada' : item.status === 'pending' ? 'Pendiente' : item.status || ''}`;
|
||||
|
||||
// Update detail grid
|
||||
const detailGrid = modalCard.querySelector('div:nth-child(2)');
|
||||
if (detailGrid) {
|
||||
detailGrid.innerHTML = `
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-4); margin-bottom:var(--space-6);">
|
||||
<div>
|
||||
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-1);">Emisor</div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.emisor_name || 'Nexus Autoparts SA de CV'}</div>
|
||||
<div style="font-family:var(--font-mono); font-size:var(--text-body-sm); color:var(--color-text-secondary);">${item.emisor_rfc || ''}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-1);">Receptor</div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.customer_name || '-'}</div>
|
||||
<div style="font-family:var(--font-mono); font-size:var(--text-body-sm); color:var(--color-text-secondary);">${item.rfc || ''}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-1);">UUID</div>
|
||||
<div style="font-family:var(--font-mono); font-size:var(--text-caption); color:var(--color-text-accent); word-break:break-all;">${item.uuid_fiscal || 'Sin timbrar'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-1);">Total</div>
|
||||
<div style="font-family:var(--font-mono); font-size:var(--text-h4); font-weight:var(--font-weight-bold); color:var(--color-text-primary);">$${fmt(item.total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
${item.error_message ? `<p style="color:var(--color-error);margin-bottom:var(--space-3);"><strong>Error:</strong> ${escapeHtml(item.error_message)}</p>` : ''}
|
||||
${(item.xml_signed || item.xml_unsigned) ? `
|
||||
<div style="font-size:var(--text-caption); color:var(--color-text-muted); text-transform:uppercase; letter-spacing:var(--tracking-widest); margin-bottom:var(--space-2);">Vista previa XML</div>
|
||||
<pre style="background:var(--color-surface-3); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-4); font-family:var(--font-mono); font-size:11px; color:var(--color-text-secondary); overflow-x:auto; max-height:200px; line-height:1.6;">${escapeHtml(item.xml_signed || item.xml_unsigned)}</pre>
|
||||
` : ''}`;
|
||||
}
|
||||
|
||||
// XML preview
|
||||
const xml = item.xml_signed || item.xml_unsigned;
|
||||
if (xml) {
|
||||
html += `<h4>XML</h4><div class="xml-preview">${escapeHtml(xml)}</div>`;
|
||||
// Wire the cancel button inside modal footer
|
||||
const cancelBtn = modalCard.querySelector('div:last-child button:last-child');
|
||||
if (cancelBtn && item.status === 'stamped') {
|
||||
cancelBtn.style.display = '';
|
||||
cancelBtn.onclick = () => {
|
||||
overlay.style.display = 'none';
|
||||
showCancelModal(cfdiId);
|
||||
};
|
||||
} else if (cancelBtn) {
|
||||
cancelBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('detail-content').innerHTML = html;
|
||||
document.getElementById('detail-modal').classList.add('active');
|
||||
// Store current CFDI id for use by footer buttons
|
||||
overlay.dataset.cfdiId = cfdiId;
|
||||
overlay.dataset.saleId = item.sale_id || '';
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
alert('Error al cargar detalle: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,42 +334,31 @@ const Invoicing = (() => {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ─── Process Queue ─────────────────────────────
|
||||
|
||||
async function processQueue() {
|
||||
if (!confirm('Procesar todos los CFDIs pendientes?')) return;
|
||||
try {
|
||||
const result = await api('/queue/process', { method: 'POST' });
|
||||
alert(`Procesados: ${result.processed}, Timbrados: ${result.stamped}, Fallidos: ${result.failed}`);
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cancel ────────────────────────────────────
|
||||
// ---- Cancel modal (uses modalCancelOverlay) ----
|
||||
let cancelTargetId = null;
|
||||
|
||||
function showCancelModal(cfdiId) {
|
||||
document.getElementById('cancel-cfdi-id').value = cfdiId;
|
||||
document.getElementById('cancel-motive').value = '';
|
||||
document.getElementById('cancel-replacement-uuid').value = '';
|
||||
document.getElementById('replacement-uuid-group').style.display = 'none';
|
||||
document.getElementById('cancel-modal').classList.add('active');
|
||||
}
|
||||
|
||||
function onMotiveChange() {
|
||||
const motive = document.getElementById('cancel-motive').value;
|
||||
document.getElementById('replacement-uuid-group').style.display =
|
||||
motive === '01' ? 'block' : 'none';
|
||||
cancelTargetId = cfdiId;
|
||||
const overlay = document.getElementById('modalCancelOverlay');
|
||||
if (overlay) overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function confirmCancel() {
|
||||
const cfdiId = document.getElementById('cancel-cfdi-id').value;
|
||||
const motive = document.getElementById('cancel-motive').value;
|
||||
const replacementUuid = document.getElementById('cancel-replacement-uuid').value;
|
||||
if (!cancelTargetId) return;
|
||||
const overlay = document.getElementById('modalCancelOverlay');
|
||||
if (!overlay) return;
|
||||
|
||||
if (!motive) { alert('Selecciona un motivo de cancelacion.'); return; }
|
||||
if (motive === '01' && !replacementUuid) { alert('UUID sustituto requerido para motivo 01.'); return; }
|
||||
const selectedRadio = overlay.querySelector('input[name="motivo-sat"]:checked');
|
||||
if (!selectedRadio) { alert('Selecciona un motivo de cancelacion.'); return; }
|
||||
const motive = selectedRadio.value;
|
||||
|
||||
const uuidInput = overlay.querySelector('input[type="text"]');
|
||||
const replacementUuid = uuidInput ? uuidInput.value.trim() : '';
|
||||
|
||||
if (motive === '01' && !replacementUuid) {
|
||||
alert('UUID sustituto requerido para motivo 01.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Confirmar cancelacion ante el SAT?')) return;
|
||||
|
||||
@@ -199,29 +366,83 @@ const Invoicing = (() => {
|
||||
const body = { motive };
|
||||
if (replacementUuid) body.replacement_uuid = replacementUuid;
|
||||
|
||||
await api(`/cancel/${cfdiId}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
closeModal('cancel-modal');
|
||||
loadQueue();
|
||||
await api(`/cancel/${cancelTargetId}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
overlay.style.display = 'none';
|
||||
cancelTargetId = null;
|
||||
loadFacturas();
|
||||
alert('CFDI cancelado exitosamente.');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal helpers ─────────────────────────────
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
// ---- Process entire queue ----
|
||||
async function processQueue() {
|
||||
if (!confirm('Procesar todos los CFDIs pendientes?')) return;
|
||||
try {
|
||||
const result = await api('/queue/process', { method: 'POST' });
|
||||
alert(`Procesados: ${result.processed}, Timbrados: ${result.stamped}, Fallidos: ${result.failed}`);
|
||||
loadFacturas();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ──────────────────────────────────────
|
||||
// ---- Clock ----
|
||||
function startClock() {
|
||||
const el = document.getElementById('live-clock');
|
||||
if (!el) return;
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
el.textContent = now.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadQueue();
|
||||
});
|
||||
// ---- Wire cancel modal "Solicitar Cancelacion SAT" button ----
|
||||
function wireCancelModal() {
|
||||
const overlay = document.getElementById('modalCancelOverlay');
|
||||
if (!overlay) return;
|
||||
const footerBtns = overlay.querySelectorAll('div:last-child button');
|
||||
if (footerBtns.length >= 2) {
|
||||
// Last button = confirm cancel
|
||||
footerBtns[footerBtns.length - 1].onclick = () => confirmCancel();
|
||||
// Second to last = close
|
||||
footerBtns[footerBtns.length - 2].onclick = () => {
|
||||
overlay.style.display = 'none';
|
||||
cancelTargetId = null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Wire detail modal close button ----
|
||||
function wireDetailModal() {
|
||||
const overlay = document.getElementById('modalDetalleOverlay');
|
||||
if (!overlay) return;
|
||||
const closeBtn = overlay.querySelector('button[onclick*="modalDetalleOverlay"]');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => { overlay.style.display = 'none'; };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Init ----
|
||||
function init() {
|
||||
if (!checkAuth()) return;
|
||||
startClock();
|
||||
wireDetailModal();
|
||||
wireCancelModal();
|
||||
// Load initial tab data (facturas is active by default)
|
||||
loadFacturas();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
// Expose switchTab globally for onclick handlers in HTML
|
||||
window.switchTab = switchTab;
|
||||
|
||||
return {
|
||||
loadQueue, processQueue, showDetail, showCancelModal,
|
||||
onMotiveChange, confirmCancel, closeModal,
|
||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
|
||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1172,6 +1172,141 @@
|
||||
padding: var(--space-1); opacity: 0.7; color: inherit;
|
||||
}
|
||||
.banner__dismiss:hover { opacity: 1; }
|
||||
|
||||
/* =========================================================================
|
||||
INVENTORY MODALS
|
||||
========================================================================= */
|
||||
|
||||
.inv-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inv-modal-overlay.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inv-modal {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 520px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.inv-modal--wide {
|
||||
width: 700px;
|
||||
}
|
||||
|
||||
.inv-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);
|
||||
}
|
||||
|
||||
.inv-modal__header h3 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: var(--heading-weight-primary);
|
||||
font-size: var(--text-h5);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inv-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 var(--space-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.inv-modal__close:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.inv-modal__body {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.inv-modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.inv-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.inv-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.inv-field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.inv-field label {
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.inv-field input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.inv-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.count-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.count-row input {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--color-surface-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* History table inside modal */
|
||||
.inv-modal .data-table { width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -1430,7 +1565,7 @@
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" placeholder="Buscar por SKU, nombre, marca…" />
|
||||
<input type="text" id="productSearch" placeholder="Buscar por SKU, nombre, marca…" />
|
||||
</div>
|
||||
<select class="select-filter">
|
||||
<option value="">Todas las marcas</option>
|
||||
@@ -1455,125 +1590,34 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
Columnas
|
||||
</button>
|
||||
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Producto
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<table class="data-table" id="stockTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Nombre del Producto</th>
|
||||
<th>Barcode</th>
|
||||
<th>No. Parte</th>
|
||||
<th>Nombre</th>
|
||||
<th>Marca</th>
|
||||
<th>Categoría</th>
|
||||
<th style="text-align:right">Stock</th>
|
||||
<th style="text-align:right">Mín</th>
|
||||
<th style="text-align:right">Máx</th>
|
||||
<th style="text-align:right">Costo</th>
|
||||
<th style="text-align:right">Precio 1</th>
|
||||
<th style="text-align:right">Precio 2</th>
|
||||
<th style="text-align:right">Precio 3</th>
|
||||
<th>Ubicación</th>
|
||||
<th>Estado</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="td--mono">GAT-50104</td>
|
||||
<td class="td--primary">Banda de Tiempo Kit</td>
|
||||
<td>Gates</td>
|
||||
<td>Motor</td>
|
||||
<td style="text-align:right" class="td--primary">142</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">20</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">200</td>
|
||||
<td>A-12-3</td>
|
||||
<td><span class="badge badge--ok">OK</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">SKF-6204</td>
|
||||
<td class="td--primary">Rodamiento Rueda Delantera</td>
|
||||
<td>SKF</td>
|
||||
<td>Suspensión</td>
|
||||
<td style="text-align:right" class="td--primary">8</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">15</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">60</td>
|
||||
<td>B-05-1</td>
|
||||
<td><span class="badge badge--low">Bajo</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">BOC-0258</td>
|
||||
<td class="td--primary">Bujía Iridium BKR6EIX</td>
|
||||
<td>Bosch</td>
|
||||
<td>Eléctrico</td>
|
||||
<td style="text-align:right" class="td--primary">3</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">10</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">100</td>
|
||||
<td>C-08-2</td>
|
||||
<td><span class="badge badge--low">Bajo</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">MON-G8114</td>
|
||||
<td class="td--primary">Amortiguador Trasero Derecho</td>
|
||||
<td>Monroe</td>
|
||||
<td>Suspensión</td>
|
||||
<td style="text-align:right" class="td--primary">67</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">10</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">40</td>
|
||||
<td>B-14-5</td>
|
||||
<td><span class="badge badge--over">Sobrestock</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">NGK-LFR6A</td>
|
||||
<td class="td--primary">Bujía Laser Platinum LFR6A</td>
|
||||
<td>NGK</td>
|
||||
<td>Eléctrico</td>
|
||||
<td style="text-align:right" class="td--primary">88</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">25</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">120</td>
|
||||
<td>C-02-4</td>
|
||||
<td><span class="badge badge--ok">OK</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">BOC-F026</td>
|
||||
<td class="td--primary">Filtro de Aceite F026407006</td>
|
||||
<td>Bosch</td>
|
||||
<td>Filtros</td>
|
||||
<td style="text-align:right" class="td--primary">5</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">20</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">80</td>
|
||||
<td>D-01-2</td>
|
||||
<td><span class="badge badge--low">Bajo</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">ATE-13046</td>
|
||||
<td class="td--primary">Pastillas de Freno ATE Power</td>
|
||||
<td>ATE</td>
|
||||
<td>Frenos</td>
|
||||
<td style="text-align:right" class="td--primary">34</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">15</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">50</td>
|
||||
<td>E-03-1</td>
|
||||
<td><span class="badge badge--ok">OK</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="td--mono">TRW-GDB1</td>
|
||||
<td class="td--primary">Disco de Freno Ventilado</td>
|
||||
<td>TRW</td>
|
||||
<td>Frenos</td>
|
||||
<td style="text-align:right" class="td--primary">12</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">10</td>
|
||||
<td style="text-align:right;color:var(--color-text-muted)">30</td>
|
||||
<td>E-07-3</td>
|
||||
<td><span class="badge badge--ok">OK</span></td>
|
||||
</tr>
|
||||
<tbody id="productTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<span>Mostrando 1–8 de 4,817 productos</span>
|
||||
<div class="pagination">
|
||||
<button class="page-btn">‹</button>
|
||||
<button class="page-btn is-active">1</button>
|
||||
<button class="page-btn">2</button>
|
||||
<button class="page-btn">3</button>
|
||||
<span style="padding:0 var(--space-1);color:var(--color-text-muted)">…</span>
|
||||
<button class="page-btn">602</button>
|
||||
<button class="page-btn">›</button>
|
||||
</div>
|
||||
<div id="productPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1594,7 +1638,7 @@
|
||||
</select>
|
||||
<input type="date" class="select-filter" value="2026-04-01" />
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary">
|
||||
<button class="btn btn--primary" onclick="showPurchaseModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nueva Entrada
|
||||
</button>
|
||||
@@ -1801,7 +1845,7 @@
|
||||
<option>En tránsito</option><option>Recibido</option><option>Pendiente</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary">
|
||||
<button class="btn btn--primary" onclick="showTransferModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Traspaso
|
||||
</button>
|
||||
@@ -1888,7 +1932,7 @@
|
||||
<option>Merma</option><option>Daño</option><option>Corrección</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary">
|
||||
<button class="btn btn--primary" onclick="showAdjustmentModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Ajuste
|
||||
</button>
|
||||
@@ -1983,7 +2027,7 @@
|
||||
<option>Completado</option><option>En proceso</option><option>Programado</option>
|
||||
</select>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary">
|
||||
<button class="btn btn--primary" onclick="showCountModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Programar Conteo
|
||||
</button>
|
||||
@@ -2072,7 +2116,7 @@
|
||||
TAB 7 — ALERTAS
|
||||
=================================================================== -->
|
||||
<div class="tab-panel" id="panel-alertas" role="tabpanel">
|
||||
|
||||
<div id="alertsContent">
|
||||
<!-- Low Stock -->
|
||||
<div class="section-heading">
|
||||
<span class="section-heading__title">Stock Bajo</span>
|
||||
@@ -2251,6 +2295,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div><!-- /alertsContent -->
|
||||
</div>
|
||||
|
||||
</div><!-- /tab-panels -->
|
||||
@@ -2334,6 +2379,142 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- ===== MODALS ===== -->
|
||||
|
||||
<!-- Create Item Modal -->
|
||||
<div class="inv-modal-overlay" id="createModal">
|
||||
<div class="inv-modal">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Nuevo Producto</h3>
|
||||
<button class="inv-modal__close" onclick="closeCreateModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div class="inv-form-grid">
|
||||
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
|
||||
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
|
||||
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
|
||||
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
|
||||
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Precio 3</label><input type="number" id="newPrice3" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
|
||||
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
|
||||
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
|
||||
</div>
|
||||
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
|
||||
<button class="btn btn--primary" onclick="createItem()">Crear Producto</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchase Modal -->
|
||||
<div class="inv-modal-overlay" id="purchaseModal">
|
||||
<div class="inv-modal">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Registrar Compra / Entrada</h3>
|
||||
<button class="inv-modal__close" onclick="closePurchaseModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div class="inv-form-grid">
|
||||
<div class="inv-field"><label>ID Producto *</label><input type="number" id="purchaseItemId" placeholder="ID inventario" /></div>
|
||||
<div class="inv-field"><label>Cantidad *</label><input type="number" id="purchaseQty" placeholder="Cantidad" /></div>
|
||||
<div class="inv-field"><label>Costo Unitario *</label><input type="number" id="purchaseCost" step="0.01" placeholder="0.00" /></div>
|
||||
<div class="inv-field"><label>Factura Proveedor</label><input type="text" id="purchaseInvoice" placeholder="No. factura" /></div>
|
||||
<div class="inv-field inv-field--full"><label>Notas</label><input type="text" id="purchaseNotes" placeholder="Notas adicionales" /></div>
|
||||
</div>
|
||||
<div id="purchaseResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closePurchaseModal()">Cancelar</button>
|
||||
<button class="btn btn--primary" onclick="recordPurchase()">Registrar Compra</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Modal -->
|
||||
<div class="inv-modal-overlay" id="transferModal">
|
||||
<div class="inv-modal">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Nuevo Traspaso</h3>
|
||||
<button class="inv-modal__close" onclick="closeTransferModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div class="inv-form-grid">
|
||||
<div class="inv-field"><label>ID Producto *</label><input type="number" id="transferItemId" placeholder="ID inventario" /></div>
|
||||
<div class="inv-field"><label>Cantidad *</label><input type="number" id="transferQty" placeholder="Cantidad" /></div>
|
||||
<div class="inv-field"><label>Sucursal Origen *</label><input type="number" id="transferFrom" placeholder="ID sucursal origen" /></div>
|
||||
<div class="inv-field"><label>Sucursal Destino *</label><input type="number" id="transferTo" placeholder="ID sucursal destino" /></div>
|
||||
<div class="inv-field inv-field--full"><label>Notas</label><input type="text" id="transferNotes" placeholder="Notas adicionales" /></div>
|
||||
</div>
|
||||
<div id="transferResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeTransferModal()">Cancelar</button>
|
||||
<button class="btn btn--primary" onclick="recordTransfer()">Registrar Traspaso</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adjustment Modal -->
|
||||
<div class="inv-modal-overlay" id="adjustmentModal">
|
||||
<div class="inv-modal">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Nuevo Ajuste</h3>
|
||||
<button class="inv-modal__close" onclick="closeAdjustmentModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div class="inv-form-grid">
|
||||
<div class="inv-field"><label>ID Producto *</label><input type="number" id="adjustItemId" placeholder="ID inventario" /></div>
|
||||
<div class="inv-field"><label>Cantidad * (negativo=salida)</label><input type="number" id="adjustQty" placeholder="Ej: -3 o +5" /></div>
|
||||
<div class="inv-field inv-field--full"><label>Razón / Motivo *</label><input type="text" id="adjustReason" placeholder="Motivo del ajuste (obligatorio)" /></div>
|
||||
</div>
|
||||
<div id="adjustResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeAdjustmentModal()">Cancelar</button>
|
||||
<button class="btn btn--primary" onclick="recordAdjustment()">Registrar Ajuste</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Count Modal -->
|
||||
<div class="inv-modal-overlay" id="countModal">
|
||||
<div class="inv-modal inv-modal--wide">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Conteo Físico</h3>
|
||||
<button class="inv-modal__close" onclick="closeCountModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div id="countLines"></div>
|
||||
<button class="btn btn--ghost btn--sm" onclick="addCountLine()" style="margin-top:var(--space-2);">+ Agregar Línea</button>
|
||||
<div id="countResults" style="margin-top:var(--space-4);"></div>
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeCountModal()">Cerrar</button>
|
||||
<button class="btn btn--primary" onclick="startPhysicalCount()">Iniciar Conteo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Modal -->
|
||||
<div class="inv-modal-overlay" id="historyModal">
|
||||
<div class="inv-modal inv-modal--wide">
|
||||
<div class="inv-modal__header">
|
||||
<h3>Historial de Movimientos</h3>
|
||||
<button class="inv-modal__close" onclick="closeHistoryModal()">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body" id="historyContent">
|
||||
</div>
|
||||
<div class="inv-modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeHistoryModal()">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
Reference in New Issue
Block a user