841 lines
45 KiB
JavaScript
841 lines
45 KiB
JavaScript
// /home/Autopartes/pos/static/js/reports.js
|
|
// Reports module: sales reports, inventory reports, financial reports
|
|
|
|
const Reports = (() => {
|
|
function token() {
|
|
return localStorage.getItem('pos_token') || '';
|
|
}
|
|
|
|
function checkAuth() {
|
|
if (!token()) {
|
|
window.location.href = '/pos/login';
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function headers() {
|
|
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
function fmt(n) {
|
|
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
function fmtInt(n) {
|
|
return parseInt(n || 0).toLocaleString('es-MX');
|
|
}
|
|
|
|
function fmtDate(s) {
|
|
if (!s) return '--';
|
|
var d = new Date(s);
|
|
if (isNaN(d)) return s;
|
|
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
function fmtDateTime(s) {
|
|
if (!s) return '--';
|
|
var d = new Date(s);
|
|
if (isNaN(d)) return s;
|
|
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' }) +
|
|
' ' + d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function spinner() {
|
|
return '<div style="text-align:center;padding:var(--space-6);color:var(--color-text-muted)">Cargando...</div>';
|
|
}
|
|
|
|
function emptyMsg(text) {
|
|
return '<div style="text-align:center;padding:var(--space-6);color:var(--color-text-muted)">' + text + '</div>';
|
|
}
|
|
|
|
function errorMsg(text) {
|
|
return '<div style="text-align:center;padding:var(--space-6);color:var(--color-error)">' + text + '</div>';
|
|
}
|
|
|
|
// Track which tabs have been loaded
|
|
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Theme switcher
|
|
// -------------------------------------------------------------------------
|
|
function setTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
try { localStorage.setItem('pos_theme', theme); } catch(e) {}
|
|
var btnInd = document.getElementById('btn-industrial');
|
|
var btnMod = document.getElementById('btn-modern');
|
|
if (btnInd) btnInd.classList.toggle('is-active', theme === 'industrial');
|
|
if (btnMod) btnMod.classList.toggle('is-active', theme === 'modern');
|
|
}
|
|
window.setTheme = setTheme;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Tab switcher with lazy loading
|
|
// -------------------------------------------------------------------------
|
|
function switchTab(id, btn) {
|
|
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
|
|
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
|
var panel = document.getElementById('panel-' + id);
|
|
if (panel) panel.classList.add('is-active');
|
|
if (btn) btn.classList.add('is-active');
|
|
|
|
// Lazy load on first visit
|
|
if (!loaded[id]) {
|
|
if (id === 'ventas') loadVentas();
|
|
else if (id === 'inventario') loadInventario();
|
|
else if (id === 'clientes') loadClientes();
|
|
else if (id === 'financieros') loadFinancieros();
|
|
else if (id === 'historico') loadHistorico();
|
|
}
|
|
}
|
|
window.switchTab = switchTab;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Live clock
|
|
// -------------------------------------------------------------------------
|
|
function updateClock() {
|
|
var el = document.getElementById('live-clock');
|
|
if (!el) return;
|
|
var now = new Date();
|
|
var pad = function(n) { return String(n).padStart(2, '0'); };
|
|
el.textContent = pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds());
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Generic fetch helper
|
|
// -------------------------------------------------------------------------
|
|
async function apiFetch(url) {
|
|
var resp = await fetch(url, { headers: headers() });
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
return resp.json();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// KPI card builder
|
|
// -------------------------------------------------------------------------
|
|
function kpiCard(label, value, sub) {
|
|
return '<div class="kpi-card">' +
|
|
'<div class="kpi-card__label">' + label + '</div>' +
|
|
'<div class="kpi-card__value">' + value + '</div>' +
|
|
(sub ? '<div class="kpi-card__sub">' + sub + '</div>' : '') +
|
|
'</div>';
|
|
}
|
|
|
|
// =========================================================================
|
|
// TAB 1: VENTAS
|
|
// =========================================================================
|
|
async function loadVentas() {
|
|
loaded.ventas = true;
|
|
var dateFrom = document.getElementById('ventas-date-from').value;
|
|
var dateTo = document.getElementById('ventas-date-to').value;
|
|
|
|
var params = new URLSearchParams();
|
|
if (dateFrom) params.set('date_from', dateFrom);
|
|
if (dateTo) params.set('date_to', dateTo);
|
|
params.set('per_page', '200');
|
|
|
|
// Show spinners
|
|
var kpiEl = document.getElementById('ventas-kpis');
|
|
var barEl = document.getElementById('ventas-bar-chart');
|
|
var vendedorEl = document.getElementById('ventas-por-vendedor');
|
|
var metodoEl = document.getElementById('ventas-por-metodo');
|
|
var detalleEl = document.getElementById('ventas-detalle');
|
|
|
|
kpiEl.innerHTML = spinner();
|
|
barEl.innerHTML = '';
|
|
vendedorEl.innerHTML = spinner();
|
|
metodoEl.innerHTML = spinner();
|
|
detalleEl.innerHTML = spinner();
|
|
|
|
try {
|
|
// Fetch all pages to get complete data for the period
|
|
var allSales = [];
|
|
var page = 1;
|
|
var totalPages = 1;
|
|
|
|
while (page <= totalPages) {
|
|
params.set('page', page);
|
|
var json = await apiFetch('/pos/api/sales?' + params.toString());
|
|
allSales = allSales.concat(json.data || []);
|
|
totalPages = json.pagination ? json.pagination.total_pages : 1;
|
|
page++;
|
|
if (page > 50) break; // safety limit
|
|
}
|
|
|
|
var sales = allSales.filter(function(s) { return s.status === 'completed'; });
|
|
|
|
// KPIs
|
|
var totalVentas = sales.reduce(function(a, s) { return a + s.total; }, 0);
|
|
var numVentas = sales.length;
|
|
var ticketProm = numVentas > 0 ? totalVentas / numVentas : 0;
|
|
|
|
kpiEl.innerHTML =
|
|
kpiCard('Total Ventas', '$' + fmt(totalVentas), numVentas + ' transacciones') +
|
|
kpiCard('Ticket Promedio', '$' + fmt(ticketProm), '') +
|
|
kpiCard('Transacciones', fmtInt(numVentas), '') +
|
|
kpiCard('Descuentos', '$' + fmt(sales.reduce(function(a, s) { return a + s.discount_total; }, 0)), '');
|
|
|
|
// Bar chart: sales by day
|
|
var byDay = {};
|
|
var dayNames = ['Dom', 'Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab'];
|
|
sales.forEach(function(s) {
|
|
var d = s.created_at.substring(0, 10);
|
|
byDay[d] = (byDay[d] || 0) + s.total;
|
|
});
|
|
var days = Object.keys(byDay).sort().slice(-7);
|
|
var maxDay = Math.max.apply(null, days.map(function(d) { return byDay[d]; })) || 1;
|
|
|
|
if (days.length > 0) {
|
|
var barHtml = '<div class="bar-chart-card__title">Ventas por Dia</div><div class="bar-chart">';
|
|
days.forEach(function(d) {
|
|
var val = byDay[d];
|
|
var pct = Math.round(val / maxDay * 100);
|
|
var label = dayNames[new Date(d + 'T12:00:00').getDay()];
|
|
var valStr = val >= 1000 ? '$' + (val / 1000).toFixed(1) + 'k' : '$' + fmt(val);
|
|
barHtml += '<div class="bar-chart__col"><div class="bar-chart__bar-wrap">' +
|
|
'<div class="bar-chart__bar" style="height:' + pct + '%">' +
|
|
'<span class="bar-chart__bar-val">' + valStr + '</span></div></div>' +
|
|
'<div class="bar-chart__day">' + label + '</div></div>';
|
|
});
|
|
barHtml += '</div>';
|
|
barEl.innerHTML = barHtml;
|
|
}
|
|
|
|
// Ventas por vendedor
|
|
var byEmployee = {};
|
|
sales.forEach(function(s) {
|
|
var key = s.employee_id || 0;
|
|
if (!byEmployee[key]) {
|
|
byEmployee[key] = { name: s.employee_name || 'Sin asignar', count: 0, total: 0 };
|
|
}
|
|
byEmployee[key].count++;
|
|
byEmployee[key].total += s.total;
|
|
});
|
|
var empList = Object.values(byEmployee).sort(function(a, b) { return b.total - a.total; });
|
|
|
|
var empHtml = '<div class="table-card__header"><span class="table-card__title">Ventas por Vendedor</span></div>';
|
|
empHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Vendedor</th><th class="align-right"># Ventas</th>' +
|
|
'<th class="align-right">Total</th><th class="align-right">Ticket Prom.</th></tr></thead><tbody>';
|
|
empList.forEach(function(e) {
|
|
var initials = e.name.split(' ').map(function(w) { return w[0]; }).join('').substring(0, 2).toUpperCase();
|
|
empHtml += '<tr><td><div style="display:flex;align-items:center;gap:var(--space-2)">' +
|
|
'<div style="width:28px;height:28px;background:var(--color-primary);color:var(--color-text-inverse);display:flex;align-items:center;justify-content:center;font-size:0.65rem;font-weight:700;flex-shrink:0;border-radius:var(--radius-full)">' + initials + '</div>' +
|
|
'<span class="td-strong">' + e.name + '</span></div></td>' +
|
|
'<td class="align-right td-mono">' + e.count + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(e.total) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(e.count > 0 ? e.total / e.count : 0) + '</td></tr>';
|
|
});
|
|
empHtml += '</tbody></table></div>';
|
|
vendedorEl.innerHTML = empList.length ? empHtml : emptyMsg('Sin datos de vendedores');
|
|
|
|
// Ventas por metodo de pago
|
|
var byMethod = {};
|
|
sales.forEach(function(s) {
|
|
var m = s.payment_method || 'Otro';
|
|
byMethod[m] = (byMethod[m] || 0) + s.total;
|
|
});
|
|
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
|
|
var maxMethod = methods.length > 0 ? methods[0][1] : 1;
|
|
var methodLabels = {
|
|
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
|
|
'credit': 'Credito', 'mixed': 'Mixto'
|
|
};
|
|
var barColors = ['', 'pay-method__bar--b', 'pay-method__bar--c', 'pay-method__bar--d'];
|
|
|
|
var metHtml = '<div class="table-card__header"><span class="table-card__title">Ventas por Metodo de Pago</span></div>';
|
|
metHtml += '<div class="table-wrap" style="padding:var(--space-4) var(--space-5)">';
|
|
methods.forEach(function(m, idx) {
|
|
var pct = totalVentas > 0 ? Math.round(m[1] / totalVentas * 100) : 0;
|
|
var label = methodLabels[m[0]] || m[0];
|
|
metHtml += '<div class="pay-method-row"><span class="pay-method__label">' + label + '</span>' +
|
|
'<div class="pay-method__bar-wrap"><div class="pay-method__bar ' + (barColors[idx] || '') + '" style="width:' + pct + '%"></div></div>' +
|
|
'<span class="pay-method__val">$' + fmt(m[1]) + ' <span style="color:var(--color-text-muted);font-weight:400">' + pct + '%</span></span></div>';
|
|
});
|
|
metHtml += '</div>';
|
|
metodoEl.innerHTML = methods.length ? metHtml : emptyMsg('Sin datos de metodos');
|
|
|
|
// Sales detail table
|
|
var dtlHtml = '<div class="table-card__header"><span class="table-card__title">Detalle de Ventas</span>' +
|
|
'<span class="pill pill--muted">' + allSales.length + ' registros</span></div>';
|
|
dtlHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>#</th><th>Fecha</th><th>Vendedor</th><th>Cliente</th><th>Pago</th>' +
|
|
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
|
|
'<th class="align-right">IVA</th><th class="align-right">Total</th><th>Estado</th>' +
|
|
'</tr></thead><tbody>';
|
|
allSales.slice(0, 100).forEach(function(s) {
|
|
var statusPill = s.status === 'completed' ? 'pill--success' :
|
|
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
|
|
var statusLabel = s.status === 'completed' ? 'Completada' :
|
|
s.status === 'cancelled' ? 'Cancelada' : s.status;
|
|
dtlHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
|
|
'<td>' + fmtDateTime(s.created_at) + '</td>' +
|
|
'<td>' + (s.employee_name || '--') + '</td>' +
|
|
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
|
|
'<td>' + (methodLabels[s.payment_method] || s.payment_method || '--') + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(s.tax_total) + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
|
|
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
|
|
});
|
|
dtlHtml += '</tbody></table></div>';
|
|
detalleEl.innerHTML = dtlHtml;
|
|
|
|
} catch (err) {
|
|
kpiEl.innerHTML = errorMsg('Error cargando ventas: ' + err.message);
|
|
vendedorEl.innerHTML = '';
|
|
metodoEl.innerHTML = '';
|
|
detalleEl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// TAB 5: HISTÓRICO
|
|
// =========================================================================
|
|
async function loadHistorico() {
|
|
loaded.historico = true;
|
|
var dateFrom = document.getElementById('historico-date-from').value;
|
|
var dateTo = document.getElementById('historico-date-to').value;
|
|
var customer = document.getElementById('historico-customer').value.trim();
|
|
|
|
var params = new URLSearchParams();
|
|
if (dateFrom) params.set('date_from', dateFrom);
|
|
if (dateTo) params.set('date_to', dateTo);
|
|
if (customer) params.set('customer', customer);
|
|
params.set('per_page', '200');
|
|
|
|
var kpiEl = document.getElementById('historico-kpis');
|
|
var detalleEl = document.getElementById('historico-detalle');
|
|
kpiEl.innerHTML = spinner();
|
|
detalleEl.innerHTML = spinner();
|
|
|
|
try {
|
|
var allRows = [];
|
|
var page = 1;
|
|
var totalPages = 1;
|
|
|
|
while (page <= totalPages) {
|
|
params.set('page', page);
|
|
var json = await apiFetch('/pos/api/historical-sales?' + params.toString());
|
|
allRows = allRows.concat(json.data || []);
|
|
totalPages = json.pagination ? json.pagination.total_pages : 1;
|
|
page++;
|
|
if (page > 50) break;
|
|
}
|
|
|
|
var total = allRows.reduce(function(a, r) { return a + r.total; }, 0);
|
|
var subtotal = allRows.reduce(function(a, r) { return a + r.subtotal; }, 0);
|
|
var balance = allRows.reduce(function(a, r) { return a + r.balance; }, 0);
|
|
|
|
kpiEl.innerHTML =
|
|
kpiCard('Total Histórico', '$' + fmt(total), allRows.length + ' registros') +
|
|
kpiCard('Subtotal', '$' + fmt(subtotal), '') +
|
|
kpiCard('Saldo Pendiente', '$' + fmt(balance), '') +
|
|
kpiCard('Tickets', fmtInt(allRows.length), '');
|
|
|
|
var html = '<div class="table-card__header"><span class="table-card__title">Ventas Históricas Importadas</span>' +
|
|
'<span class="pill pill--muted">' + allRows.length + ' registros</span></div>';
|
|
html += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Fecha</th><th>Documento</th><th>Cliente</th><th>Pago</th>' +
|
|
'<th class="align-right">Subtotal</th><th class="align-right">Total</th>' +
|
|
'<th class="align-right">Pagado</th><th class="align-right">Saldo</th>' +
|
|
'</tr></thead><tbody>';
|
|
allRows.slice(0, 200).forEach(function(r) {
|
|
html += '<tr>' +
|
|
'<td>' + fmtDate(r.sale_date) + '</td>' +
|
|
'<td class="td-mono">' + esc(r.document_no || r.external_document_id || '--') + '</td>' +
|
|
'<td>' + esc(r.customer_name || '--') + '</td>' +
|
|
'<td><span class="pill pill--muted">' + esc(r.payment_method || '--') + '</span></td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.subtotal) + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(r.total) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.amount_paid) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.balance) + '</td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
detalleEl.innerHTML = html;
|
|
|
|
} catch (err) {
|
|
kpiEl.innerHTML = errorMsg('Error cargando histórico: ' + err.message);
|
|
detalleEl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/[&<>"']/g, function(c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// TAB 2: INVENTARIO
|
|
// =========================================================================
|
|
async function loadInventario() {
|
|
loaded.inventario = true;
|
|
var kpiEl = document.getElementById('inventario-kpis');
|
|
var valEl = document.getElementById('inventario-valorizacion');
|
|
var abcEl = document.getElementById('inventario-abc');
|
|
var lowEl = document.getElementById('inventario-low-stock');
|
|
var noMoveEl = document.getElementById('inventario-no-movement');
|
|
|
|
kpiEl.innerHTML = spinner();
|
|
valEl.innerHTML = spinner();
|
|
abcEl.innerHTML = spinner();
|
|
lowEl.innerHTML = spinner();
|
|
noMoveEl.innerHTML = spinner();
|
|
|
|
try {
|
|
var [valData, abcData, lowData, noMoveData] = await Promise.all([
|
|
apiFetch('/pos/api/inventory/reports/valuation'),
|
|
apiFetch('/pos/api/inventory/reports/abc'),
|
|
apiFetch('/pos/api/inventory/reports/low-stock'),
|
|
apiFetch('/pos/api/inventory/reports/no-movement')
|
|
]);
|
|
|
|
// KPIs
|
|
kpiEl.innerHTML =
|
|
kpiCard('Valor Total Inventario', '$' + fmt(valData.grand_total), fmtInt(valData.item_count) + ' SKUs activos') +
|
|
kpiCard('Clasificacion A', fmtInt(abcData.summary.A) + ' SKUs', '80% del volumen de ventas') +
|
|
kpiCard('Stock Bajo', fmtInt(lowData.count) + ' productos', 'debajo del minimo') +
|
|
kpiCard('Sin Movimiento', fmtInt(noMoveData.count) + ' productos', '>' + noMoveData.days_threshold + ' dias');
|
|
|
|
// Valuation table (top 20)
|
|
var vItems = (valData.data || []).slice(0, 20);
|
|
var vHtml = '<div class="table-card__header"><span class="table-card__title">Inventario Valorizado</span>' +
|
|
'<span class="pill pill--muted">Top 20 de ' + fmtInt(valData.item_count) + '</span></div>';
|
|
vHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Producto</th><th>No. Parte</th><th>Marca</th>' +
|
|
'<th class="align-right">Stock</th><th class="align-right">Costo Unit.</th>' +
|
|
'<th class="align-right">Valor</th></tr></thead><tbody>';
|
|
vItems.forEach(function(i) {
|
|
vHtml += '<tr><td class="td-strong">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + (i.part_number || '--') + '</td>' +
|
|
'<td>' + (i.brand || '--') + '</td>' +
|
|
'<td class="align-right td-mono">' + fmtInt(i.stock) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.cost) + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(i.value) + '</td></tr>';
|
|
});
|
|
vHtml += '</tbody></table></div>';
|
|
valEl.innerHTML = vHtml;
|
|
|
|
// ABC table
|
|
var abcItems = (abcData.data || []).slice(0, 30);
|
|
var abcHtml = '<div class="table-card__header"><span class="table-card__title">Clasificacion ABC de Inventario</span>' +
|
|
'<span class="pill pill--success">A: ' + abcData.summary.A + '</span> ' +
|
|
'<span class="pill pill--warning">B: ' + abcData.summary.B + '</span> ' +
|
|
'<span class="pill pill--muted">C: ' + abcData.summary.C + '</span></div>';
|
|
abcHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Producto</th><th>No. Parte</th><th>Marca</th>' +
|
|
'<th class="align-right">Vol. Ventas</th><th class="align-right">% Acum.</th>' +
|
|
'<th class="align-center">Clase</th></tr></thead><tbody>';
|
|
abcItems.forEach(function(i) {
|
|
var clsPill = i.classification === 'A' ? 'pill--success' :
|
|
i.classification === 'B' ? 'pill--warning' : 'pill--muted';
|
|
abcHtml += '<tr><td class="td-strong">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + (i.part_number || '--') + '</td>' +
|
|
'<td>' + (i.brand || '--') + '</td>' +
|
|
'<td class="align-right td-mono">' + fmtInt(i.sales_volume) + '</td>' +
|
|
'<td class="align-right td-mono">' + i.cumulative_pct + '%</td>' +
|
|
'<td class="align-center"><span class="pill ' + clsPill + '">' + i.classification + '</span></td></tr>';
|
|
});
|
|
abcHtml += '</tbody></table></div>';
|
|
abcEl.innerHTML = abcHtml;
|
|
|
|
// Low stock
|
|
var lowItems = lowData.data || [];
|
|
var lowHtml = '<div class="table-card__header"><span class="table-card__title">Productos con Stock Bajo</span>' +
|
|
'<span class="pill pill--warning pill--dot">' + lowData.count + ' productos</span></div>';
|
|
lowHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Producto</th><th>No. Parte</th><th>Marca</th>' +
|
|
'<th class="align-right">Stock</th><th class="align-right">Minimo</th>' +
|
|
'<th class="align-right">Deficit</th></tr></thead><tbody>';
|
|
lowItems.slice(0, 30).forEach(function(i) {
|
|
var stockColor = i.stock <= 0 ? 'color:var(--color-error)' :
|
|
i.stock < i.min_stock / 2 ? 'color:var(--color-error)' : 'color:var(--color-warning)';
|
|
lowHtml += '<tr><td class="td-strong">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + (i.part_number || '--') + '</td>' +
|
|
'<td>' + (i.brand || '--') + '</td>' +
|
|
'<td class="align-right td-mono" style="' + stockColor + '">' + fmtInt(i.stock) + '</td>' +
|
|
'<td class="align-right td-mono">' + fmtInt(i.min_stock) + '</td>' +
|
|
'<td class="align-right td-mono-accent">' + fmtInt(i.deficit) + '</td></tr>';
|
|
});
|
|
lowHtml += '</tbody></table></div>';
|
|
lowEl.innerHTML = lowItems.length ? lowHtml : emptyMsg('No hay productos con stock bajo');
|
|
|
|
// No movement
|
|
var noItems = noMoveData.data || [];
|
|
var noHtml = '<div class="table-card__header"><span class="table-card__title">Productos Sin Movimiento (>' + noMoveData.days_threshold + ' dias)</span>' +
|
|
'<span class="pill pill--muted">' + noMoveData.count + ' SKUs</span></div>';
|
|
noHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Producto</th><th>No. Parte</th><th>Marca</th>' +
|
|
'<th class="align-right">Stock</th><th class="align-right">Costo Unit.</th>' +
|
|
'<th>Ultimo Movimiento</th></tr></thead><tbody>';
|
|
noItems.slice(0, 30).forEach(function(i) {
|
|
noHtml += '<tr><td class="td-strong">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + (i.part_number || '--') + '</td>' +
|
|
'<td>' + (i.brand || '--') + '</td>' +
|
|
'<td class="align-right td-mono">' + fmtInt(i.stock) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.cost) + '</td>' +
|
|
'<td style="color:var(--color-text-muted)">' + fmtDate(i.last_movement) + '</td></tr>';
|
|
});
|
|
noHtml += '</tbody></table></div>';
|
|
noMoveEl.innerHTML = noItems.length ? noHtml : emptyMsg('No hay productos sin movimiento');
|
|
|
|
} catch (err) {
|
|
kpiEl.innerHTML = errorMsg('Error cargando inventario: ' + err.message);
|
|
valEl.innerHTML = '';
|
|
abcEl.innerHTML = '';
|
|
lowEl.innerHTML = '';
|
|
noMoveEl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// TAB 3: CLIENTES (Aging report)
|
|
// =========================================================================
|
|
async function loadClientes() {
|
|
loaded.clientes = true;
|
|
var kpiEl = document.getElementById('clientes-kpis');
|
|
var agingEl = document.getElementById('clientes-aging');
|
|
|
|
kpiEl.innerHTML = spinner();
|
|
agingEl.innerHTML = spinner();
|
|
|
|
try {
|
|
var data = await apiFetch('/pos/api/accounting/aging');
|
|
var clients = data.data || [];
|
|
var totals = data.totals || {};
|
|
|
|
// KPIs
|
|
kpiEl.innerHTML =
|
|
kpiCard('Clientes con Credito', fmtInt(clients.length), 'con saldo pendiente') +
|
|
kpiCard('Saldo Total', '$' + fmt(totals.total), '') +
|
|
kpiCard('Corriente', '$' + fmt(totals.corriente), 'no vencido') +
|
|
kpiCard('Vencido >90 dias', '$' + fmt(totals.d90_plus),
|
|
totals.d90_plus > 0 ? '<span class="kpi-card__delta kpi-card__delta--down">requiere atencion</span>' : '');
|
|
|
|
// Aging table
|
|
var html = '<div class="table-card__header"><span class="table-card__title">Antiguedad de Saldos</span>' +
|
|
'<span class="pill pill--error pill--dot">' + clients.length + ' clientes</span></div>';
|
|
html += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Cliente</th><th>RFC</th>' +
|
|
'<th class="align-right">Corriente</th><th class="align-right">1-30 dias</th>' +
|
|
'<th class="align-right">31-60 dias</th><th class="align-right">61-90 dias</th>' +
|
|
'<th class="align-right">90+ dias</th><th class="align-right">Total</th>' +
|
|
'</tr></thead><tbody>';
|
|
|
|
clients.forEach(function(c) {
|
|
html += '<tr><td class="td-strong">' + c.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + (c.rfc || '--') + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(c.corriente) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(c.d1_30) + '</td>' +
|
|
'<td class="align-right td-mono"' + (c.d31_60 > 0 ? ' style="color:var(--color-warning)"' : '') + '>$' + fmt(c.d31_60) + '</td>' +
|
|
'<td class="align-right td-mono"' + (c.d61_90 > 0 ? ' style="color:var(--color-error)"' : '') + '>$' + fmt(c.d61_90) + '</td>' +
|
|
'<td class="align-right td-mono"' + (c.d90_plus > 0 ? ' style="color:var(--color-error);font-weight:700"' : '') + '>$' + fmt(c.d90_plus) + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(c.total) + '</td></tr>';
|
|
});
|
|
|
|
// Totals row
|
|
html += '<tr style="background:var(--color-surface-2);font-weight:700">' +
|
|
'<td colspan="2">TOTAL</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(totals.corriente) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(totals.d1_30) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(totals.d31_60) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(totals.d61_90) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(totals.d90_plus) + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(totals.total) + '</td></tr>';
|
|
|
|
html += '</tbody></table></div>';
|
|
agingEl.innerHTML = clients.length ? html : emptyMsg('No hay saldos pendientes de credito');
|
|
|
|
} catch (err) {
|
|
kpiEl.innerHTML = errorMsg('Error cargando datos de clientes: ' + err.message);
|
|
agingEl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// TAB 4: FINANCIEROS
|
|
// =========================================================================
|
|
async function loadFinancieros() {
|
|
loaded.financieros = true;
|
|
var monthSel = document.getElementById('fin-month');
|
|
var yearSel = document.getElementById('fin-year');
|
|
var year = parseInt(yearSel.value);
|
|
var month = parseInt(monthSel.value);
|
|
|
|
var kpiEl = document.getElementById('financieros-kpis');
|
|
var incomeEl = document.getElementById('financieros-income');
|
|
var balanceEl = document.getElementById('financieros-balance');
|
|
var trialEl = document.getElementById('financieros-trial');
|
|
var cortesEl = document.getElementById('financieros-cortes');
|
|
|
|
kpiEl.innerHTML = spinner();
|
|
incomeEl.innerHTML = spinner();
|
|
balanceEl.innerHTML = spinner();
|
|
trialEl.innerHTML = spinner();
|
|
cortesEl.innerHTML = spinner();
|
|
|
|
try {
|
|
var [incData, balData, trialData, cortesData] = await Promise.all([
|
|
apiFetch('/pos/api/accounting/income-statement?year=' + year + '&month=' + month),
|
|
apiFetch('/pos/api/accounting/balance-sheet'),
|
|
apiFetch('/pos/api/accounting/trial-balance?year=' + year + '&month=' + month),
|
|
apiFetch('/pos/api/register/history?per_page=50')
|
|
]);
|
|
|
|
// KPIs from income statement
|
|
kpiEl.innerHTML =
|
|
kpiCard('Ingresos', '$' + fmt(incData.ingresos.total), 'periodo ' + month + '/' + year) +
|
|
kpiCard('Costos', '$' + fmt(incData.costos.total), '') +
|
|
kpiCard('Utilidad Bruta', '$' + fmt(incData.utilidad_bruta), '') +
|
|
kpiCard('Utilidad Neta', '$' + fmt(incData.utilidad_neta),
|
|
incData.ingresos.total > 0 ? 'Margen: ' + (incData.utilidad_neta / incData.ingresos.total * 100).toFixed(1) + '%' : '');
|
|
|
|
// Income statement
|
|
var iHtml = '<div class="table-card__header"><span class="table-card__title">Estado de Resultados</span>' +
|
|
'<span class="pill pill--muted">' + month + '/' + year + '</span></div>';
|
|
iHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Cuenta</th><th>Codigo</th><th class="align-right">Monto</th></tr></thead><tbody>';
|
|
|
|
// Ingresos section
|
|
iHtml += '<tr style="background:var(--color-surface-2)"><td colspan="2" class="td-strong">INGRESOS</td><td></td></tr>';
|
|
(incData.ingresos.items || []).forEach(function(i) {
|
|
iHtml += '<tr><td style="padding-left:var(--space-6)">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + i.code + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.amount) + '</td></tr>';
|
|
});
|
|
iHtml += '<tr style="font-weight:700"><td style="padding-left:var(--space-6)">Total Ingresos</td><td></td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(incData.ingresos.total) + '</td></tr>';
|
|
|
|
// Costos section
|
|
iHtml += '<tr style="background:var(--color-surface-2)"><td colspan="2" class="td-strong">COSTOS</td><td></td></tr>';
|
|
(incData.costos.items || []).forEach(function(i) {
|
|
iHtml += '<tr><td style="padding-left:var(--space-6)">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + i.code + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.amount) + '</td></tr>';
|
|
});
|
|
iHtml += '<tr style="font-weight:700"><td style="padding-left:var(--space-6)">Total Costos</td><td></td>' +
|
|
'<td class="align-right td-mono">$' + fmt(incData.costos.total) + '</td></tr>';
|
|
|
|
// Utilidad bruta
|
|
iHtml += '<tr style="background:var(--color-primary-muted);font-weight:700"><td>UTILIDAD BRUTA</td><td></td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(incData.utilidad_bruta) + '</td></tr>';
|
|
|
|
// Gastos section
|
|
iHtml += '<tr style="background:var(--color-surface-2)"><td colspan="2" class="td-strong">GASTOS</td><td></td></tr>';
|
|
(incData.gastos.items || []).forEach(function(i) {
|
|
iHtml += '<tr><td style="padding-left:var(--space-6)">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + i.code + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.amount) + '</td></tr>';
|
|
});
|
|
iHtml += '<tr style="font-weight:700"><td style="padding-left:var(--space-6)">Total Gastos</td><td></td>' +
|
|
'<td class="align-right td-mono">$' + fmt(incData.gastos.total) + '</td></tr>';
|
|
|
|
// Utilidad neta
|
|
var netColor = incData.utilidad_neta >= 0 ? 'var(--color-success)' : 'var(--color-error)';
|
|
iHtml += '<tr style="background:var(--color-primary-muted);font-weight:700"><td>UTILIDAD NETA</td><td></td>' +
|
|
'<td class="align-right" style="font-family:var(--font-mono);color:' + netColor + '">$' + fmt(incData.utilidad_neta) + '</td></tr>';
|
|
iHtml += '</tbody></table></div>';
|
|
incomeEl.innerHTML = iHtml;
|
|
|
|
// Balance sheet
|
|
var bHtml = '<div class="table-card__header"><span class="table-card__title">Balance General</span>' +
|
|
'<span class="pill ' + (balData.balanced ? 'pill--success' : 'pill--error') + '">' +
|
|
(balData.balanced ? 'Cuadrado' : 'Descuadrado') + '</span></div>';
|
|
bHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Cuenta</th><th>Codigo</th><th class="align-right">Saldo</th></tr></thead><tbody>';
|
|
|
|
// Activo
|
|
bHtml += '<tr style="background:var(--color-surface-2)"><td colspan="2" class="td-strong">ACTIVO</td><td></td></tr>';
|
|
(balData.activo.items || []).forEach(function(i) {
|
|
bHtml += '<tr><td style="padding-left:var(--space-6)">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + i.code + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.balance) + '</td></tr>';
|
|
});
|
|
bHtml += '<tr style="font-weight:700"><td>Total Activo</td><td></td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(balData.activo.total) + '</td></tr>';
|
|
|
|
// Pasivo
|
|
bHtml += '<tr style="background:var(--color-surface-2)"><td colspan="2" class="td-strong">PASIVO</td><td></td></tr>';
|
|
(balData.pasivo.items || []).forEach(function(i) {
|
|
bHtml += '<tr><td style="padding-left:var(--space-6)">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + i.code + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.balance) + '</td></tr>';
|
|
});
|
|
bHtml += '<tr style="font-weight:700"><td>Total Pasivo</td><td></td>' +
|
|
'<td class="align-right td-mono">$' + fmt(balData.pasivo.total) + '</td></tr>';
|
|
|
|
// Capital
|
|
bHtml += '<tr style="background:var(--color-surface-2)"><td colspan="2" class="td-strong">CAPITAL</td><td></td></tr>';
|
|
(balData.capital.items || []).forEach(function(i) {
|
|
bHtml += '<tr><td style="padding-left:var(--space-6)">' + i.name + '</td>' +
|
|
'<td class="td-mono" style="color:var(--color-text-muted)">' + i.code + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(i.balance) + '</td></tr>';
|
|
});
|
|
bHtml += '<tr style="font-weight:700"><td>Total Capital</td><td></td>' +
|
|
'<td class="align-right td-mono">$' + fmt(balData.capital.total) + '</td></tr>';
|
|
|
|
bHtml += '<tr style="background:var(--color-primary-muted);font-weight:700"><td>Pasivo + Capital</td><td></td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(balData.pasivo.total + balData.capital.total) + '</td></tr>';
|
|
bHtml += '</tbody></table></div>';
|
|
balanceEl.innerHTML = bHtml;
|
|
|
|
// Trial balance
|
|
var tRows = trialData.data || [];
|
|
var tHtml = '<div class="table-card__header"><span class="table-card__title">Balanza de Comprobacion</span>' +
|
|
'<span class="pill pill--muted">' + month + '/' + year + '</span></div>';
|
|
tHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Codigo</th><th>Cuenta</th><th>Tipo</th>' +
|
|
'<th class="align-right">Saldo Inicial</th><th class="align-right">Cargos</th>' +
|
|
'<th class="align-right">Abonos</th><th class="align-right">Saldo Final</th>' +
|
|
'</tr></thead><tbody>';
|
|
tRows.forEach(function(r) {
|
|
tHtml += '<tr><td class="td-mono">' + r.code + '</td>' +
|
|
'<td class="td-strong">' + r.name + '</td>' +
|
|
'<td><span class="pill pill--muted">' + r.type + '</span></td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.saldo_inicial) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.cargos) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.abonos) + '</td>' +
|
|
'<td class="align-right td-mono-accent">$' + fmt(r.saldo_final) + '</td></tr>';
|
|
});
|
|
tHtml += '</tbody></table></div>';
|
|
trialEl.innerHTML = tRows.length ? tHtml : emptyMsg('Sin movimientos contables en este periodo');
|
|
|
|
// Cortes de caja
|
|
var regs = cortesData.data || [];
|
|
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
|
|
'<span class="pill pill--muted">' + (cortesData.pagination ? cortesData.pagination.total : regs.length) + ' cortes</span></div>';
|
|
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
|
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
|
|
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
|
|
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
|
|
'</tr></thead><tbody>';
|
|
regs.forEach(function(r) {
|
|
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
|
|
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
|
|
cHtml += '<tr><td class="td-mono">#' + r.register_number + '</td>' +
|
|
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
|
|
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
|
|
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
|
|
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
|
|
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td></tr>';
|
|
});
|
|
cHtml += '</tbody></table></div>';
|
|
cortesEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja registrados');
|
|
|
|
} catch (err) {
|
|
kpiEl.innerHTML = errorMsg('Error cargando reportes financieros: ' + err.message);
|
|
incomeEl.innerHTML = '';
|
|
balanceEl.innerHTML = '';
|
|
trialEl.innerHTML = '';
|
|
cortesEl.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Init
|
|
// -------------------------------------------------------------------------
|
|
function init() {
|
|
if (!checkAuth()) return;
|
|
|
|
// Restore theme
|
|
try {
|
|
var saved = localStorage.getItem('pos_theme') || 'industrial';
|
|
setTheme(saved);
|
|
} catch(e) {}
|
|
|
|
// Start clock
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
|
|
// Set default date range: first day of current month to today
|
|
var now = new Date();
|
|
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
var fromEl = document.getElementById('ventas-date-from');
|
|
var toEl = document.getElementById('ventas-date-to');
|
|
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
|
if (toEl) toEl.value = now.toISOString().substring(0, 10);
|
|
|
|
// Populate financial period selectors
|
|
var monthSel = document.getElementById('fin-month');
|
|
var yearSel = document.getElementById('fin-year');
|
|
if (monthSel) {
|
|
var monthNames = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
|
for (var m = 0; m < 12; m++) {
|
|
var opt = document.createElement('option');
|
|
opt.value = m + 1;
|
|
opt.textContent = monthNames[m];
|
|
if (m === now.getMonth()) opt.selected = true;
|
|
monthSel.appendChild(opt);
|
|
}
|
|
}
|
|
if (yearSel) {
|
|
for (var y = now.getFullYear(); y >= now.getFullYear() - 3; y--) {
|
|
var opt = document.createElement('option');
|
|
opt.value = y;
|
|
opt.textContent = y;
|
|
yearSel.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
// Load the default active tab (ventas)
|
|
loadVentas();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
return {
|
|
init, setTheme, switchTab,
|
|
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
|
|
};
|
|
// Register Cmd+K items
|
|
if (typeof registerCmdKItem === "function") {
|
|
registerCmdKItem({ group: "Principal", label: "Reportes", href: "/pos/reports", icon: "📈" });
|
|
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
|
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
|
}
|
|
|
|
})();
|
|
|
|
// ── Global: Export visible table as CSV (Excel-compatible) ──
|
|
function exportReportCSV() {
|
|
var tables = document.querySelectorAll('table');
|
|
// Find the first visible table
|
|
var table = null;
|
|
for (var i = 0; i < tables.length; i++) {
|
|
var t = tables[i];
|
|
if (t.offsetParent !== null && t.querySelector('tbody tr')) {
|
|
table = t;
|
|
break;
|
|
}
|
|
}
|
|
if (!table) {
|
|
alert('No hay tabla de datos para exportar en esta vista.');
|
|
return;
|
|
}
|
|
var rows = [];
|
|
var ths = table.querySelectorAll('thead th');
|
|
if (ths.length) {
|
|
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
|
}
|
|
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
|
var cells = tr.querySelectorAll('td');
|
|
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
|
});
|
|
if (rows.length <= 1) { alert('La tabla esta vacia.'); return; }
|
|
var csv = rows.join('\n');
|
|
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'reporte_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|