fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-06-29 23:54:58 +00:00
parent 59a4893e84
commit 2bdeb2973a
61 changed files with 2879 additions and 706 deletions

View File

@@ -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> &nbsp;|&nbsp; <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)} &nbsp;&middot;&nbsp; ${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);
}