feat(admin): HTML leads list with filter + top empresas + download stats
This commit is contained in:
30
app/admin.py
30
app/admin.py
@@ -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)):
|
||||||
|
|||||||
91
app/templates/admin_leads.html
Normal file
91
app/templates/admin_leads.html
Normal 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 %}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user