feat(admin): live-updating leads view with auto-refresh, sort, filter, +N badge

This commit is contained in:
2026-05-13 16:23:11 +00:00
parent 7b1b28ead8
commit f8c7139e6e
2 changed files with 268 additions and 55 deletions

View File

@@ -5,87 +5,247 @@
<section class="admin-section">
<div class="admin-header">
<div>
<p class="eyebrow-light">ADMIN</p>
<p class="eyebrow-light">ADMIN · EN VIVO</p>
<h1>Leads registrados</h1>
<p class="admin-sub">{{ total }} registros · {{ total_downloads }} descargas totales</p>
<p class="admin-sub">
<span id="total-leads"></span> registros ·
<span id="total-downloads"></span> descargas totales
</p>
</div>
<div class="admin-actions">
<div class="live-status">
<span class="dot dot-live" id="live-dot"></span>
<span class="live-label" id="live-label">en vivo · 10s</span>
</div>
<button type="button" class="btn btn-secondary btn-icon" id="pause-btn" title="Pausar / reanudar auto-refresh"></button>
<button type="button" class="btn btn-secondary btn-icon" id="refresh-btn" title="Actualizar ahora"></button>
<a class="btn btn-secondary" href="/admin/leads.csv">Exportar CSV</a>
<a class="btn btn-secondary" href="/admin/stats">Ver stats JSON</a>
</div>
</div>
<div class="admin-filters">
<input type="text" id="filter" placeholder="Filtrar por nombre, email o empresa..." oninput="filterTable()">
<input type="text" id="filter" placeholder="Filtrar por nombre, email o empresa...">
<span class="last-updated" id="last-updated"></span>
</div>
<div class="admin-table-wrap">
<table class="admin-table" id="leads-table">
<thead>
<tr>
<th>#</th>
<th>Nombre</th>
<th>Email</th>
<th>Empresa</th>
<th class="num">Reg.</th>
<th>Creado</th>
<th>Último</th>
<th data-sort="id" class="sortable num">#<span class="sort-ind"></span></th>
<th data-sort="nombre" class="sortable">Nombre<span class="sort-ind"></span></th>
<th data-sort="email" class="sortable">Email<span class="sort-ind"></span></th>
<th data-sort="empresa" class="sortable">Empresa<span class="sort-ind"></span></th>
<th data-sort="times_registered" class="sortable num">Reg.<span class="sort-ind"></span></th>
<th data-sort="created_at" class="sortable">Creado<span class="sort-ind"></span></th>
<th data-sort="last_seen" class="sortable">Último<span class="sort-ind"></span></th>
<th class="hide-sm">IP</th>
<th class="hide-sm">Consent</th>
</tr>
</thead>
<tbody>
{% for l in leads %}
<tr>
<td class="muted">{{ l.id }}</td>
<td><strong>{{ l.nombre }}</strong></td>
<td><a href="mailto:{{ l.email }}">{{ l.email }}</a></td>
<td>{{ l.empresa }}</td>
<td class="num">{{ l.times_registered }}</td>
<td class="muted">{{ l.created_at }}</td>
<td class="muted">{{ l.last_seen }}</td>
<td class="muted mono hide-sm">{{ l.ip or '—' }}</td>
<td class="hide-sm">{% if l.consent %}<span class="pill pill-ok"></span>{% else %}<span class="pill pill-no">×</span>{% endif %}</td>
</tr>
{% endfor %}
{% if not leads %}
<tr><td colspan="9" class="empty">No hay leads registrados todavía.</td></tr>
{% endif %}
<tbody id="leads-tbody">
<tr><td colspan="9" class="empty">Cargando…</td></tr>
</tbody>
</table>
</div>
{% if top_empresas %}
<div class="admin-stats-grid">
<div class="admin-stats">
<h2 class="admin-h2">Top empresas</h2>
<ul class="empresa-list">
{% for e in top_empresas %}
<li><span class="empresa-name">{{ e.empresa }}</span><span class="empresa-count">{{ e.count }}</span></li>
{% endfor %}
<ul class="empresa-list" id="empresas-list">
<li class="empty">Cargando…</li>
</ul>
</div>
{% endif %}
{% if downloads_por_archivo %}
<div class="admin-stats">
<h2 class="admin-h2">Descargas por archivo</h2>
<ul class="empresa-list">
{% for fname, count in downloads_por_archivo.items() %}
<li><span class="empresa-name mono">{{ fname }}</span><span class="empresa-count">{{ count }}</span></li>
{% endfor %}
<ul class="empresa-list" id="downloads-list">
<li class="empty">Cargando…</li>
</ul>
</div>
{% endif %}
</div>
</section>
<script>
function filterTable() {
const q = document.getElementById('filter').value.toLowerCase();
const rows = document.querySelectorAll('#leads-table tbody tr');
rows.forEach(r => {
const t = r.textContent.toLowerCase();
r.style.display = t.includes(q) ? '' : 'none';
const REFRESH_MS = 10000;
let paused = false;
let timer = null;
let lastFetch = null;
let sortKey = 'created_at';
let sortDir = -1; // -1 desc, 1 asc
let currentLeads = [];
let currentStats = null;
let filterText = '';
const $ = id => document.getElementById(id);
function fmtDate(s) {
if (!s) return '—';
return s.replace('T', ' ').slice(0, 19);
}
function escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
function renderLeads() {
const tbody = $('leads-tbody');
let rows = currentLeads.slice();
// sort
rows.sort((a, b) => {
const va = a[sortKey], vb = b[sortKey];
if (va == null) return 1;
if (vb == null) return -1;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * sortDir;
return String(va).localeCompare(String(vb)) * sortDir;
});
// filter
if (filterText) {
const q = filterText.toLowerCase();
rows = rows.filter(l =>
(l.nombre || '').toLowerCase().includes(q) ||
(l.email || '').toLowerCase().includes(q) ||
(l.empresa || '').toLowerCase().includes(q)
);
}
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="empty">Sin resultados.</td></tr>';
return;
}
tbody.innerHTML = rows.map(l => `
<tr>
<td class="muted">${l.id}</td>
<td><strong>${escape(l.nombre)}</strong></td>
<td><a href="mailto:${escape(l.email)}">${escape(l.email)}</a></td>
<td>${escape(l.empresa)}</td>
<td class="num">${l.times_registered}</td>
<td class="muted">${fmtDate(l.created_at)}</td>
<td class="muted">${fmtDate(l.last_seen)}</td>
<td class="muted mono hide-sm">${escape(l.ip) || '—'}</td>
<td class="hide-sm">${l.consent ? '<span class="pill pill-ok">✓</span>' : '<span class="pill pill-no">×</span>'}</td>
</tr>
`).join('');
}
function renderStats() {
if (!currentStats) return;
$('total-leads').textContent = currentStats.total_leads;
$('total-downloads').textContent = currentStats.total_downloads;
const empList = $('empresas-list');
if (currentStats.top_5_empresas && currentStats.top_5_empresas.length) {
empList.innerHTML = currentStats.top_5_empresas.map(e =>
`<li><span class="empresa-name">${escape(e.empresa)}</span><span class="empresa-count">${e.count}</span></li>`
).join('');
} else {
empList.innerHTML = '<li class="empty">Sin datos</li>';
}
const dlList = $('downloads-list');
const entries = Object.entries(currentStats.downloads_por_archivo || {});
if (entries.length) {
dlList.innerHTML = entries.map(([k, v]) =>
`<li><span class="empresa-name mono">${escape(k)}</span><span class="empresa-count">${v}</span></li>`
).join('');
} else {
dlList.innerHTML = '<li class="empty">Sin descargas</li>';
}
}
function renderSortIndicators() {
document.querySelectorAll('th.sortable').forEach(th => {
const ind = th.querySelector('.sort-ind');
if (th.dataset.sort === sortKey) {
ind.textContent = sortDir === -1 ? ' ↓' : ' ↑';
th.classList.add('active');
} else {
ind.textContent = '';
th.classList.remove('active');
}
});
}
async function refresh() {
try {
const [leadsResp, statsResp] = await Promise.all([
fetch('/admin/leads.json?limit=10000', { credentials: 'include' }),
fetch('/admin/stats', { credentials: 'include' }),
]);
if (!leadsResp.ok || !statsResp.ok) throw new Error('HTTP ' + leadsResp.status);
const newLeads = await leadsResp.json();
currentStats = await statsResp.json();
// Detect new leads since last refresh
if (lastFetch !== null && newLeads.length > currentLeads.length) {
flashNewBadge(newLeads.length - currentLeads.length);
}
currentLeads = newLeads;
lastFetch = new Date();
renderLeads();
renderStats();
$('last-updated').textContent = 'Actualizado: ' + lastFetch.toLocaleTimeString('es', {hour12: false});
$('live-dot').classList.remove('dot-error');
$('live-dot').classList.add('dot-live');
} catch (err) {
$('live-dot').classList.remove('dot-live');
$('live-dot').classList.add('dot-error');
$('live-label').textContent = 'error de conexión';
console.error(err);
}
}
function flashNewBadge(count) {
const label = $('live-label');
const original = paused ? 'pausado' : 'en vivo · 10s';
label.textContent = `+${count} nuevo${count > 1 ? 's' : ''}`;
label.classList.add('flash');
setTimeout(() => {
label.textContent = original;
label.classList.remove('flash');
}, 4000);
}
function startTimer() {
stopTimer();
if (!paused) timer = setInterval(refresh, REFRESH_MS);
}
function stopTimer() {
if (timer) clearInterval(timer);
timer = null;
}
document.addEventListener('DOMContentLoaded', () => {
$('filter').addEventListener('input', e => {
filterText = e.target.value;
renderLeads();
});
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (sortKey === key) sortDir = -sortDir;
else { sortKey = key; sortDir = -1; }
renderSortIndicators();
renderLeads();
});
});
$('pause-btn').addEventListener('click', () => {
paused = !paused;
$('pause-btn').textContent = paused ? '▶' : '⏸';
$('live-label').textContent = paused ? 'pausado' : 'en vivo · 10s';
if (paused) stopTimer(); else { refresh(); startTimer(); }
});
$('refresh-btn').addEventListener('click', refresh);
renderSortIndicators();
refresh();
startTimer();
});
</script>
{% endblock %}

View File

@@ -321,7 +321,60 @@ body {
.pill-ok { background: rgba(14, 116, 144, 0.12); color: var(--teal); }
.pill-no { background: rgba(180, 83, 9, 0.12); color: var(--accent); }
.admin-stats { margin-top: 40px; }
.live-status {
display: inline-flex; align-items: center; gap: 8px;
padding: 8px 14px; background: var(--slate-50);
border: 1px solid var(--slate-200); border-radius: 6px;
}
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--slate-400);
box-shadow: 0 0 0 0 rgba(14, 116, 144, 0.4);
}
.dot-live {
background: #10B981;
animation: pulse 2s infinite;
}
.dot-error { background: var(--accent); animation: none; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45); }
70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
.live-label {
font-size: 12px; color: var(--slate-700);
font-weight: 600; letter-spacing: 0.02em;
}
.live-label.flash {
color: #047857; font-weight: 700;
animation: flash-bg 0.6s ease;
}
@keyframes flash-bg {
0% { transform: scale(1); }
50% { transform: scale(1.08); }
100% { transform: scale(1); }
}
.btn-icon {
padding: 8px 12px !important; font-size: 16px !important;
line-height: 1; min-width: 38px; text-align: center;
}
.last-updated {
margin-left: 16px; font-size: 12px; color: var(--slate-600);
font-style: italic;
}
th.sortable { cursor: pointer; user-select: none; transition: background 0.15s ease; }
th.sortable:hover { background: var(--slate-100); }
th.sortable.active { color: var(--navy); }
.sort-ind { font-weight: 700; }
.admin-stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 32px; margin-top: 40px;
}
.admin-stats { margin-top: 0; }
.admin-h2 {
color: var(--navy); font-size: 18px; font-weight: 600;
margin: 0 0 12px; letter-spacing: -0.005em;