feat(admin): live-updating leads view with auto-refresh, sort, filter, +N badge
This commit is contained in:
@@ -5,87 +5,247 @@
|
|||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow-light">ADMIN</p>
|
<p class="eyebrow-light">ADMIN · EN VIVO</p>
|
||||||
<h1>Leads registrados</h1>
|
<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>
|
||||||
<div class="admin-actions">
|
<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/leads.csv">Exportar CSV</a>
|
||||||
<a class="btn btn-secondary" href="/admin/stats">Ver stats JSON</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-filters">
|
<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>
|
||||||
|
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
<table class="admin-table" id="leads-table">
|
<table class="admin-table" id="leads-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th data-sort="id" class="sortable num">#<span class="sort-ind"></span></th>
|
||||||
<th>Nombre</th>
|
<th data-sort="nombre" class="sortable">Nombre<span class="sort-ind"></span></th>
|
||||||
<th>Email</th>
|
<th data-sort="email" class="sortable">Email<span class="sort-ind"></span></th>
|
||||||
<th>Empresa</th>
|
<th data-sort="empresa" class="sortable">Empresa<span class="sort-ind"></span></th>
|
||||||
<th class="num">Reg.</th>
|
<th data-sort="times_registered" class="sortable num">Reg.<span class="sort-ind"></span></th>
|
||||||
<th>Creado</th>
|
<th data-sort="created_at" class="sortable">Creado<span class="sort-ind"></span></th>
|
||||||
<th>Último</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">IP</th>
|
||||||
<th class="hide-sm">Consent</th>
|
<th class="hide-sm">Consent</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="leads-tbody">
|
||||||
{% for l in leads %}
|
<tr><td colspan="9" class="empty">Cargando…</td></tr>
|
||||||
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if top_empresas %}
|
<div class="admin-stats-grid">
|
||||||
<div class="admin-stats">
|
<div class="admin-stats">
|
||||||
<h2 class="admin-h2">Top empresas</h2>
|
<h2 class="admin-h2">Top empresas</h2>
|
||||||
<ul class="empresa-list">
|
<ul class="empresa-list" id="empresas-list">
|
||||||
{% for e in top_empresas %}
|
<li class="empty">Cargando…</li>
|
||||||
<li><span class="empresa-name">{{ e.empresa }}</span><span class="empresa-count">{{ e.count }}</span></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if downloads_por_archivo %}
|
|
||||||
<div class="admin-stats">
|
<div class="admin-stats">
|
||||||
<h2 class="admin-h2">Descargas por archivo</h2>
|
<h2 class="admin-h2">Descargas por archivo</h2>
|
||||||
<ul class="empresa-list">
|
<ul class="empresa-list" id="downloads-list">
|
||||||
{% for fname, count in downloads_por_archivo.items() %}
|
<li class="empty">Cargando…</li>
|
||||||
<li><span class="empresa-name mono">{{ fname }}</span><span class="empresa-count">{{ count }}</span></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function filterTable() {
|
const REFRESH_MS = 10000;
|
||||||
const q = document.getElementById('filter').value.toLowerCase();
|
let paused = false;
|
||||||
const rows = document.querySelectorAll('#leads-table tbody tr');
|
let timer = null;
|
||||||
rows.forEach(r => {
|
let lastFetch = null;
|
||||||
const t = r.textContent.toLowerCase();
|
let sortKey = 'created_at';
|
||||||
r.style.display = t.includes(q) ? '' : 'none';
|
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 => ({
|
||||||
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||||||
|
}[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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -321,7 +321,60 @@ body {
|
|||||||
.pill-ok { background: rgba(14, 116, 144, 0.12); color: var(--teal); }
|
.pill-ok { background: rgba(14, 116, 144, 0.12); color: var(--teal); }
|
||||||
.pill-no { background: rgba(180, 83, 9, 0.12); color: var(--accent); }
|
.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 {
|
.admin-h2 {
|
||||||
color: var(--navy); font-size: 18px; font-weight: 600;
|
color: var(--navy); font-size: 18px; font-weight: 600;
|
||||||
margin: 0 0 12px; letter-spacing: -0.005em;
|
margin: 0 0 12px; letter-spacing: -0.005em;
|
||||||
|
|||||||
Reference in New Issue
Block a user