252 lines
8.1 KiB
HTML
252 lines
8.1 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Admin · Leads — taller-wox{% endblock %}
|
||
|
||
{% block content %}
|
||
<section class="admin-section">
|
||
<div class="admin-header">
|
||
<div>
|
||
<p class="eyebrow-light">ADMIN · EN VIVO</p>
|
||
<h1>Leads registrados</h1>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="admin-filters">
|
||
<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 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 id="leads-tbody">
|
||
<tr><td colspan="9" class="empty">Cargando…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="admin-stats-grid">
|
||
<div class="admin-stats">
|
||
<h2 class="admin-h2">Top empresas</h2>
|
||
<ul class="empresa-list" id="empresas-list">
|
||
<li class="empty">Cargando…</li>
|
||
</ul>
|
||
</div>
|
||
<div class="admin-stats">
|
||
<h2 class="admin-h2">Descargas por archivo</h2>
|
||
<ul class="empresa-list" id="downloads-list">
|
||
<li class="empty">Cargando…</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
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 => ({
|
||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||
}[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 %}
|