fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- Arregla @require_auth, permisos, race conditions, locks de caja/stock - Elimina N+1 en layaway, flotilla, dashboard y global_invoice - Asegura folios atomicos para CFDI, ordenes de servicio y polizas - Protege client_secret de MercadoLibre en backend - Conecta botones/filtros de config, customers, accounting e invoicing - Mejora accesibilidad (labels/aria-label) y estados de carga/vacio - Limpia accounting.js obsoleto y consolida accounting.v9.js - Actualiza cache busting a v32 y Service Worker a v32 - Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md Tests: 35 passed
This commit is contained in:
@@ -367,16 +367,51 @@ const Dashboard = (() => {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Top Products (from today's sales detail)
|
||||
// 4. Credit alerts
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadCreditAlerts() {
|
||||
const data = await apiFetch('/pos/api/dashboard/credit-alerts');
|
||||
const tbody = document.getElementById('credit-alerts-tbody');
|
||||
const meta = document.getElementById('credit-alerts-meta');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">No hay créditos por vencer.</td></tr>';
|
||||
if (meta) meta.textContent = 'Vencidos: 0 / Por vencer: 0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
meta.innerHTML = `<span style="color:var(--color-error);font-weight:600;">Vencidos: ${data.overdue_count || 0}</span> | <span style="color:var(--color-warning);font-weight:600;">Por vencer: ${data.due_soon_count || 0}</span>`;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.data.map(function(r) {
|
||||
const dueDate = r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-';
|
||||
const daysText = r.days_until_due < 0 ? `${Math.abs(r.days_until_due)} días vencido` : `${r.days_until_due} días restantes`;
|
||||
const statusClass = r.status === 'overdue' ? 'error' : (r.status === 'due_soon' ? 'warning' : 'success');
|
||||
return `<tr>
|
||||
<td><span class="td-client">${escHtml(r.customer_name)}</span></td>
|
||||
<td><span class="td-mono">${escHtml(r.folio)}</span></td>
|
||||
<td>${dueDate}</td>
|
||||
<td>${daysText}</td>
|
||||
<td class="align-right"><span class="td-mono">${fmt(r.balance)}</span></td>
|
||||
<td><span class="badge badge--${statusClass}">${r.status_label}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Top Products (from today's sales detail)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadTopProducts() {
|
||||
const today = todayStr();
|
||||
// Fetch all today's sales with pagination
|
||||
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`);
|
||||
// Single optimized endpoint: returns today's top products already aggregated
|
||||
const data = await apiFetch('/pos/api/dashboard/stats');
|
||||
const container = document.getElementById('top-products-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
const top = data && data.top_products ? data.top_products : [];
|
||||
|
||||
if (!top.length) {
|
||||
container.innerHTML = renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
|
||||
title: 'Sin ventas hoy',
|
||||
@@ -386,37 +421,7 @@ const Dashboard = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch detail for each sale to get items (up to 20 sales for performance)
|
||||
const salesToFetch = data.data.slice(0, 20);
|
||||
const details = await Promise.all(
|
||||
salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`))
|
||||
);
|
||||
|
||||
// Aggregate items
|
||||
const productMap = {};
|
||||
for (const sale of details) {
|
||||
if (!sale || !sale.items) continue;
|
||||
for (const item of sale.items) {
|
||||
const key = item.part_number || item.name;
|
||||
if (!productMap[key]) {
|
||||
productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 };
|
||||
}
|
||||
productMap[key].qty += item.quantity || 0;
|
||||
productMap[key].revenue += item.subtotal || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
container.innerHTML = renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
|
||||
title: 'Sin productos vendidos',
|
||||
subtitle: 'No hay suficiente información para mostrar el ranking.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = top.slice(0, 5);
|
||||
const maxRev = sorted[0].revenue || 1;
|
||||
container.innerHTML = sorted.map((p, i) => {
|
||||
const pct = Math.round((p.revenue / maxRev) * 100);
|
||||
@@ -425,7 +430,7 @@ const Dashboard = (() => {
|
||||
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
|
||||
<div class="rank-item__info">
|
||||
<div class="rank-item__name">${escHtml(p.name)}</div>
|
||||
<div class="rank-item__sub">${escHtml(p.part_number)} · ${p.qty} pzas vendidas</div>
|
||||
<div class="rank-item__sub">${p.quantity} pzas vendidas</div>
|
||||
<div class="rank-item__bar-bg">
|
||||
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
@@ -619,11 +624,12 @@ const Dashboard = (() => {
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadRecentSales() {
|
||||
const today = todayStr();
|
||||
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
|
||||
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`);
|
||||
const tbody = document.getElementById('recent-sales-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
const sales = data && data.data ? data.data : [];
|
||||
if (!sales.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
|
||||
title: 'Sin ventas hoy',
|
||||
@@ -633,26 +639,22 @@ const Dashboard = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch items for first 5 sales
|
||||
const salesToShow = data.data.slice(0, 5);
|
||||
const details = await Promise.all(
|
||||
salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`))
|
||||
);
|
||||
const salesToShow = sales.slice(0, 5);
|
||||
|
||||
tbody.innerHTML = salesToShow.map((sale, idx) => {
|
||||
const detail = details[idx];
|
||||
tbody.innerHTML = salesToShow.map((sale) => {
|
||||
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
|
||||
const client = sale.customer_name || 'Publico General';
|
||||
const total = sale.total || 0;
|
||||
const method = sale.payment_method || 'efectivo';
|
||||
|
||||
// Build products summary from detail items
|
||||
// Build products summary from items already included in the response
|
||||
let productsSummary = '';
|
||||
if (detail && detail.items && detail.items.length > 0) {
|
||||
productsSummary = detail.items.slice(0, 3).map(it =>
|
||||
const items = sale.items || [];
|
||||
if (items.length > 0) {
|
||||
productsSummary = items.slice(0, 3).map(it =>
|
||||
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
|
||||
).join(', ');
|
||||
if (detail.items.length > 3) productsSummary += '...';
|
||||
if (items.length > 3) productsSummary += '...';
|
||||
}
|
||||
|
||||
const methodClass = getPaymentBadgeClass(method);
|
||||
@@ -701,6 +703,7 @@ const Dashboard = (() => {
|
||||
loadDailySummary();
|
||||
loadHistoricalSummary();
|
||||
loadAlerts();
|
||||
loadCreditAlerts();
|
||||
loadTopProducts();
|
||||
loadChart('semana');
|
||||
loadRecentSales();
|
||||
@@ -709,6 +712,7 @@ const Dashboard = (() => {
|
||||
setInterval(() => {
|
||||
loadDailySummary();
|
||||
loadRecentSales();
|
||||
loadCreditAlerts();
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user