Add dashboard with recent invoices, top suppliers, and summary stats

Clicking a supplier navigates to facturas filtered by that provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-17 07:18:30 +00:00
parent 74b21c7fa8
commit f88d5eb6b6
5 changed files with 319 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ const { articulosRouter } = require('./routes/articulos');
const { catalogoRouter } = require('./routes/catalogo');
const { configRouter } = require('./routes/config');
const { printRouter } = require('./routes/print');
const { dashboardRouter } = require('./routes/dashboard');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -20,6 +21,7 @@ app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// API Routes
app.use('/api/dashboard', dashboardRouter(db));
app.use('/api/facturas', facturasRouter(db));
app.use('/api/articulos', articulosRouter(db));
app.use('/api/catalogo', catalogoRouter(db));

View File

@@ -686,6 +686,107 @@ input[type="checkbox"] {
align-items: center;
}
/* ---- Dashboard ---- */
.dashboard-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
text-align: center;
}
.stat-card-accent {
background: var(--primary);
border-color: var(--primary);
color: #ffffff;
}
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1.2;
margin-bottom: 4px;
}
.stat-card-accent .stat-value {
color: #ffffff;
}
.stat-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.stat-card-accent .stat-label {
color: rgba(255, 255, 255, 0.8);
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.proveedores-grid {
display: grid;
gap: 10px;
}
.proveedor-chip {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.proveedor-chip:hover {
background: var(--primary-light);
border-color: var(--primary);
}
.proveedor-nombre {
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.proveedor-stats {
display: flex;
gap: 12px;
align-items: center;
}
.proveedor-count {
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: var(--primary-light);
padding: 2px 8px;
border-radius: 999px;
}
.proveedor-monto {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* ---- Responsive ---- */
@media (max-width: 768px) {
body {
@@ -761,6 +862,14 @@ input[type="checkbox"] {
.search-bar {
max-width: 100%;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.dashboard-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {

View File

@@ -12,6 +12,10 @@
<h1 class="sidebar-title">Portal Refaccionaria</h1>
</div>
<nav class="sidebar-nav">
<a href="#dashboard" class="nav-link" data-route="dashboard">
<span class="nav-icon">&#127968;</span>
<span class="nav-text">Inicio</span>
</a>
<a href="#facturas" class="nav-link" data-route="facturas">
<span class="nav-icon">&#128196;</span>
<span class="nav-text">Facturas</span>

View File

@@ -77,7 +77,7 @@
// ---- Router ----
function router() {
var hash = window.location.hash || '#facturas';
var hash = window.location.hash || '#dashboard';
var parts = hash.substring(1).split('/');
var route = parts[0];
var param = parts[1] || null;
@@ -96,8 +96,11 @@
showLoading();
switch (route) {
case 'dashboard':
renderDashboard();
break;
case 'facturas':
renderFacturas();
renderFacturas(parts[1] === 'proveedor' ? decodeURIComponent(parts[2] || '') : null);
break;
case 'factura':
renderFacturaDetail(param);
@@ -112,14 +115,148 @@
renderConfig();
break;
default:
renderFacturas();
renderDashboard();
}
}
// ============================================
// VIEW: Dashboard
// ============================================
async function renderDashboard() {
try {
var data = await fetchJSON('/api/dashboard');
var stats = data.stats;
// Stats cards
var statsHtml =
'<div class="dashboard-stats">' +
'<div class="stat-card">' +
'<div class="stat-value">' + stats.total_facturas + '</div>' +
'<div class="stat-label">Facturas</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-value">' + stats.total_proveedores + '</div>' +
'<div class="stat-label">Proveedores</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-value">' + stats.total_articulos + '</div>' +
'<div class="stat-label">Art\u00edculos</div>' +
'</div>' +
'<div class="stat-card stat-card-accent">' +
'<div class="stat-value">' + currencyFmt.format(stats.monto_total) + '</div>' +
'<div class="stat-label">Monto Total</div>' +
'</div>' +
'</div>';
// Recent invoices
var recientesHtml = '';
if (data.recientes.length) {
var rows = data.recientes.map(function (f) {
return '<tr class="clickable-row" data-id="' + f.id + '">' +
'<td>' + escapeHtml(formatDate(f.fecha)) + '</td>' +
'<td><code>' + escapeHtml(truncateUUID(f.uuid)) + '</code></td>' +
'<td>' + escapeHtml(f.nombre_emisor) + '</td>' +
'<td class="currency">' + currencyFmt.format(f.total || 0) + '</td>' +
'<td class="text-center">' + (f.num_conceptos || 0) + '</td>' +
'</tr>';
}).join('');
recientesHtml =
'<div class="card">' +
'<div class="card-body">' +
'<h3>Facturas Recientes</h3>' +
'<p class="text-muted" style="margin-top:4px">Facturas del \u00faltimo d\u00eda registrado</p>' +
'</div>' +
'<div class="table-container">' +
'<table>' +
'<thead><tr>' +
'<th>Fecha</th>' +
'<th>UUID</th>' +
'<th>Emisor</th>' +
'<th class="text-right">Total</th>' +
'<th class="text-center"># Art.</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>' +
'</div>';
} else {
recientesHtml =
'<div class="card">' +
'<div class="card-body">' +
'<h3>Facturas Recientes</h3>' +
'</div>' +
'<div class="empty-state">' +
'<div class="empty-icon">&#128196;</div>' +
'<p>No hay facturas importadas a\u00fan. Coloca archivos XML en la carpeta configurada.</p>' +
'</div>' +
'</div>';
}
// Top suppliers
var proveedoresHtml = '';
if (data.proveedores.length) {
var chips = data.proveedores.map(function (p) {
return '<div class="proveedor-chip" data-nombre="' + escapeHtml(p.nombre_emisor) + '">' +
'<div class="proveedor-nombre">' + escapeHtml(p.nombre_emisor) + '</div>' +
'<div class="proveedor-stats">' +
'<span class="proveedor-count">' + p.total_facturas + ' factura' + (p.total_facturas !== 1 ? 's' : '') + '</span>' +
'<span class="proveedor-monto">' + currencyFmt.format(p.monto_total || 0) + '</span>' +
'</div>' +
'</div>';
}).join('');
proveedoresHtml =
'<div class="card">' +
'<div class="card-body">' +
'<h3>Proveedores Principales</h3>' +
'<p class="text-muted" style="margin-top:4px">Haz clic en un proveedor para ver sus facturas</p>' +
'</div>' +
'<div class="card-body" style="padding-top:0">' +
'<div class="proveedores-grid">' + chips + '</div>' +
'</div>' +
'</div>';
}
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>Dashboard</h2>' +
'<p>Resumen general del portal de facturaci\u00f3n</p>' +
'</div>' +
statsHtml +
'<div class="dashboard-grid">' +
recientesHtml +
proveedoresHtml +
'</div>';
// Wire click on recent invoices
contentEl.querySelectorAll('.clickable-row').forEach(function (row) {
row.addEventListener('click', function () {
window.location.hash = '#factura/' + row.getAttribute('data-id');
});
});
// Wire click on supplier chips -> navigate to facturas filtered by supplier
contentEl.querySelectorAll('.proveedor-chip').forEach(function (chip) {
chip.addEventListener('click', function () {
var nombre = chip.getAttribute('data-nombre');
window.location.hash = '#facturas/proveedor/' + encodeURIComponent(nombre);
});
});
} catch (err) {
contentEl.innerHTML =
'<div class="page-header"><h2>Dashboard</h2></div>' +
'<div class="empty-state">' +
'<div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error al cargar el dashboard: ' + escapeHtml(err.message) + '</p>' +
'</div>';
}
}
// ============================================
// VIEW: Facturas List
// ============================================
async function renderFacturas() {
async function renderFacturas(proveedorFilter) {
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>Facturas</h2>' +
@@ -153,6 +290,11 @@
'</div>' +
'</div>';
// Pre-fill supplier filter if coming from dashboard
if (proveedorFilter) {
document.getElementById('f-proveedor').value = proveedorFilter;
}
// Wire up filter button and enter key
document.getElementById('f-btn-buscar').addEventListener('click', loadFacturas);
['f-fecha-inicio', 'f-fecha-fin', 'f-proveedor', 'f-buscar'].forEach(function (id) {
@@ -791,7 +933,7 @@
window.addEventListener('hashchange', router);
window.addEventListener('DOMContentLoaded', function () {
if (!window.location.hash) {
window.location.hash = '#facturas';
window.location.hash = '#dashboard';
} else {
router();
}

57
src/routes/dashboard.js Normal file
View File

@@ -0,0 +1,57 @@
const { Router } = require('express');
function dashboardRouter(db) {
const router = Router();
router.get('/', (req, res) => {
// Recent invoices: last day based on most recent invoice date in DB
const latest = db.prepare('SELECT MAX(fecha) as max_fecha FROM facturas').get();
let recientes = [];
if (latest && latest.max_fecha) {
const maxDate = latest.max_fecha.substring(0, 10);
recientes = db.prepare(`
SELECT f.*, COUNT(c.id) as num_conceptos
FROM facturas f
LEFT JOIN conceptos c ON c.factura_id = f.id
WHERE f.fecha >= ?
GROUP BY f.id
ORDER BY f.fecha DESC
`).all(maxDate);
}
// Top suppliers by invoice count
const proveedores = db.prepare(`
SELECT nombre_emisor, rfc_emisor, COUNT(*) as total_facturas,
SUM(total) as monto_total
FROM facturas
GROUP BY rfc_emisor
ORDER BY total_facturas DESC
LIMIT 10
`).all();
// Summary stats
const stats = db.prepare(`
SELECT COUNT(*) as total_facturas,
COUNT(DISTINCT rfc_emisor) as total_proveedores,
COALESCE(SUM(total), 0) as monto_total
FROM facturas
`).get();
const totalArticulos = db.prepare('SELECT COUNT(*) as total FROM conceptos').get();
res.json({
recientes,
proveedores,
stats: {
total_facturas: stats.total_facturas,
total_proveedores: stats.total_proveedores,
monto_total: stats.monto_total,
total_articulos: totalArticulos.total
}
});
});
return router;
}
module.exports = { dashboardRouter };