feat(admin): HTML leads list with filter + top empresas + download stats

This commit is contained in:
2026-05-13 16:20:32 +00:00
parent b5aedb86b1
commit 7b1b28ead8
3 changed files with 220 additions and 2 deletions

View File

@@ -2,8 +2,9 @@ import csv
import io import io
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response from fastapi.responses import HTMLResponse, Response
from fastapi.templating import Jinja2Templates
from app.config import get_settings from app.config import get_settings
from app.db import list_leads, stats from app.db import list_leads, stats
@@ -12,6 +13,31 @@ from app.security import require_admin
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
_TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
@router.get("/leads.html", response_class=HTMLResponse)
@router.get("/leads", response_class=HTMLResponse)
def admin_leads_html(
request: Request,
_user: str = Depends(require_admin),
limit: int = Query(1000, ge=1, le=10000),
):
leads = list_leads(limit=limit, offset=0)
s = stats()
return templates.TemplateResponse(
"admin_leads.html",
{
"request": request,
"leads": leads,
"total": s["total_leads"],
"total_downloads": s["total_downloads"],
"top_empresas": s["top_5_empresas"],
"downloads_por_archivo": s["downloads_por_archivo"],
},
)
@router.get("/material-files") @router.get("/material-files")
def admin_material_files(_user: str = Depends(require_admin)): def admin_material_files(_user: str = Depends(require_admin)):

View File

@@ -0,0 +1,91 @@
{% 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</p>
<h1>Leads registrados</h1>
<p class="admin-sub">{{ total }} registros · {{ total_downloads }} descargas totales</p>
</div>
<div class="admin-actions">
<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()">
</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 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>
</table>
</div>
{% if top_empresas %}
<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>
</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>
</div>
{% endif %}
</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';
});
}
</script>
{% endblock %}

View File

@@ -246,6 +246,107 @@ body {
.footer-inner a:hover { color: var(--white); border-color: var(--slate-300); } .footer-inner a:hover { color: var(--white); border-color: var(--slate-300); }
.copyright { color: var(--slate-400); font-size: 12px; margin-top: 20px !important; } .copyright { color: var(--slate-400); font-size: 12px; margin-top: 20px !important; }
/* ---------- Admin ---------- */
.admin-section {
max-width: 1280px; margin: 0 auto; padding: 48px 32px 96px;
}
.admin-header {
display: flex; justify-content: space-between; align-items: flex-end;
gap: 16px; flex-wrap: wrap; margin-bottom: 32px;
border-bottom: 1px solid var(--slate-200); padding-bottom: 20px;
}
.admin-header h1 {
color: var(--navy); margin: 4px 0 6px;
font-size: 32px; font-weight: 700; letter-spacing: -0.015em;
}
.eyebrow-light {
color: var(--slate-600); font-weight: 600; font-size: 11px;
letter-spacing: 0.18em; margin: 0; text-transform: uppercase;
}
.admin-sub { color: var(--slate-600); margin: 0; font-size: 14px; }
.admin-actions { display: flex; gap: 10px; }
.btn-secondary {
background: var(--white); color: var(--navy);
border: 1.5px solid var(--slate-300);
padding: 10px 18px; font-size: 14px; border-radius: 6px;
font-weight: 600; transition: border-color 0.15s ease, background 0.15s ease;
}
.btn-secondary:hover { border-color: var(--navy); background: var(--slate-50); }
.admin-filters { margin-bottom: 16px; }
.admin-filters input {
width: 100%; max-width: 420px;
padding: 10px 14px; border: 1.5px solid var(--slate-200); border-radius: 6px;
font-size: 14px; font-family: inherit; color: var(--slate-900);
}
.admin-filters input:focus {
outline: none; border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.1);
}
.admin-table-wrap {
overflow-x: auto; border: 1px solid var(--slate-200);
border-radius: 8px; background: var(--white);
}
.admin-table {
width: 100%; border-collapse: collapse; font-size: 14px;
}
.admin-table thead th {
background: var(--slate-50); color: var(--slate-700);
text-align: left; padding: 12px 16px; font-weight: 600;
font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;
border-bottom: 1px solid var(--slate-200);
}
.admin-table thead th.num { text-align: right; }
.admin-table tbody td {
padding: 12px 16px; border-bottom: 1px solid var(--slate-100);
color: var(--slate-900); vertical-align: middle;
}
.admin-table tbody tr:hover { background: var(--slate-50); }
.admin-table tbody tr:last-child td { border-bottom: 0; }
.admin-table td.num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td.muted { color: var(--slate-600); font-size: 13px; }
.admin-table td.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
.admin-table td.empty {
text-align: center; color: var(--slate-600); padding: 48px 16px; font-style: italic;
}
.admin-table a { color: var(--teal); text-decoration: none; }
.admin-table a:hover { text-decoration: underline; }
.pill {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
font-size: 12px; font-weight: 700;
}
.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; }
.admin-h2 {
color: var(--navy); font-size: 18px; font-weight: 600;
margin: 0 0 12px; letter-spacing: -0.005em;
}
.empresa-list {
list-style: none; padding: 0; margin: 0;
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 8px;
}
.empresa-list li {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; background: var(--slate-50);
border: 1px solid var(--slate-200); border-radius: 6px;
}
.empresa-name { color: var(--slate-900); font-weight: 500; font-size: 14px; }
.empresa-count {
background: var(--navy); color: var(--white);
padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 768px) {
.hide-sm { display: none; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
.site-header { padding: 14px 20px; } .site-header { padding: 14px 20px; }
.powered-by { font-size: 11px; } .powered-by { font-size: 11px; }