feat: pagina de estadisticas de consumo (gasto mensual, top productos, graficos)

This commit is contained in:
Tatiana Villa Ema 2026-04-25 17:51:37 +02:00
parent 02b055a96b
commit 3d31d308e5
3 changed files with 415 additions and 0 deletions

86
app.py
View File

@ -438,6 +438,92 @@ def api_ocr_ticket():
except Exception as e:
return jsonify({"ok": False, "mensaje": f"Error al procesar la imagen: {str(e)}"}), 500
# -----------------------------------------------------------------------
# Estadisticas de consumo
# -----------------------------------------------------------------------
@app.route("/estadisticas")
@login_required
def estadisticas():
return render_template("estadisticas.html", nombre=session.get("nombre", ""))
@app.route("/api/estadisticas")
@login_required
def api_estadisticas():
import csv as _csv
usuario = session["usuario"]
datos_dir = BASE_DIR / "datos" / usuario
tickets_dir = get_tickets_dir(usuario)
# Contar archivos de tickets
try:
total_tickets = sum(1 for f in tickets_dir.iterdir() if f.is_file())
except Exception:
total_tickets = 0
# Leer gasto_mensual.csv
meses = []
gasto_csv = datos_dir / "gasto_mensual.csv"
if gasto_csv.exists():
with open(gasto_csv, encoding="utf-8") as fh:
for row in _csv.DictReader(fh):
try:
meses.append({"mes": str(row.get("mes", "")),
"gasto": round(float(row.get("precio_total", 0)), 2)})
except ValueError:
pass
meses.sort(key=lambda x: x["mes"])
_ES = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"]
for m in meses:
try:
partes = m["mes"].split("-")
m["label"] = _ES[int(partes[1]) - 1] + " " + partes[0][-2:]
except Exception:
m["label"] = m["mes"]
gastos_v = [m["gasto"] for m in meses]
gasto_medio = round(sum(gastos_v) / len(gastos_v), 2) if gastos_v else 0.0
gasto_anual = round(sum(gastos_v[-12:]), 2) if gastos_v else 0.0
hoy = datetime.now()
mes_actual_key = hoy.strftime("%Y-%m")
mes_ant_key = (hoy.replace(day=1) - timedelta(days=1)).strftime("%Y-%m")
gasto_mes_actual = next((m["gasto"] for m in meses if m["mes"] == mes_actual_key), 0.0)
gasto_mes_anterior = next((m["gasto"] for m in meses if m["mes"] == mes_ant_key), 0.0)
# Leer resumen_productos.csv
top_gasto = []
top_frecuencia = []
productos_unicos = 0
resumen_csv = datos_dir / "resumen_productos.csv"
if resumen_csv.exists():
with open(resumen_csv, encoding="utf-8") as fh:
rows = list(_csv.DictReader(fh))
productos_unicos = len(rows)
top_gasto = [
{"producto": r["producto"],
"gasto_total": round(float(r.get("gasto_total", 0)), 2),
"veces": int(r.get("veces_comprado", 0))}
for r in sorted(rows, key=lambda r: float(r.get("gasto_total", 0)), reverse=True)[:10]
]
top_frecuencia = [
{"producto": r["producto"],
"veces_comprado": int(r.get("veces_comprado", 0)),
"gasto_total": round(float(r.get("gasto_total", 0)), 2)}
for r in sorted(rows, key=lambda r: int(r.get("veces_comprado", 0)), reverse=True)[:10]
]
return jsonify({
"gasto_medio_mensual": gasto_medio,
"gasto_mes_actual": gasto_mes_actual,
"gasto_mes_anterior": gasto_mes_anterior,
"gasto_anual": gasto_anual,
"total_tickets": total_tickets,
"productos_unicos": productos_unicos,
"meses": meses[-18:],
"top_productos_gasto": top_gasto,
"top_productos_frecuencia": top_frecuencia,
})
# -----------------------------------------------------------------------
# Entry point
# -----------------------------------------------------------------------

328
templates/estadisticas.html Normal file
View File

@ -0,0 +1,328 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Estadísticas — Lista de la Compra</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
/* ── Tarjetas métricas ─────────────────────────────────────────── */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: .75rem;
margin-bottom: 1.5rem;
}
.metric-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: .6rem;
padding: .9rem 1rem;
text-align: center;
}
.metric-card .metric-val {
font-size: 1.6rem;
font-weight: 700;
color: var(--accent);
line-height: 1.1;
}
.metric-card .metric-lbl {
font-size: .72rem;
color: var(--text-muted);
margin-top: .25rem;
}
/* ── Gráfico de barras ─────────────────────────────────────────── */
.chart-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: .6rem;
padding: 1rem 1.25rem 1.25rem;
margin-bottom: 1.5rem;
}
.chart-section h2 {
margin: 0 0 .9rem;
font-size: .95rem;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 130px;
overflow-x: auto;
padding-bottom: 1.6rem; /* espacio para etiquetas */
position: relative;
}
.bar-col {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 28px;
height: 100%;
justify-content: flex-end;
position: relative;
}
.bar-fill {
width: 100%;
background: var(--accent);
border-radius: 3px 3px 0 0;
opacity: .85;
transition: opacity .15s;
cursor: default;
}
.bar-fill:hover { opacity: 1; }
.bar-col .bar-label {
position: absolute;
bottom: -1.4rem;
font-size: .6rem;
color: var(--text-muted);
white-space: nowrap;
transform: rotate(-40deg);
transform-origin: top left;
left: 50%;
}
.bar-col .bar-tooltip {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.75);
color: #fff;
font-size: .65rem;
padding: 2px 5px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity .1s;
}
.bar-col:hover .bar-tooltip { opacity: 1; }
/* ── Tablas top productos ──────────────────────────────────────── */
.tables-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: .75rem;
}
.table-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: .6rem;
padding: 1rem 1.25rem;
}
.table-section h2 {
margin: 0 0 .75rem;
font-size: .95rem;
}
.top-table {
width: 100%;
border-collapse: collapse;
font-size: .78rem;
}
.top-table th {
text-align: left;
color: var(--text-muted);
font-weight: 500;
padding-bottom: .4rem;
border-bottom: 1px solid var(--border);
}
.top-table td {
padding: .35rem 0;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.top-table tr:last-child td { border-bottom: none; }
.top-table .rank {
color: var(--text-muted);
font-size: .7rem;
width: 1.4rem;
}
.top-table .prod-name {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-table .num {
text-align: right;
color: var(--accent);
font-weight: 600;
white-space: nowrap;
padding-left: .5rem;
}
.bar-inline {
height: 5px;
background: var(--accent);
border-radius: 3px;
opacity: .5;
margin-top: 2px;
}
/* ── Loading / error ───────────────────────────────────────────── */
#estado-carga { color: var(--text-muted); font-size: .85rem; margin: 2rem 0; text-align: center; }
</style>
</head>
<body>
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:.25rem;">
<h1>Estadísticas</h1>
<div style="display:flex; align-items:center; gap:.75rem;">
<a href="/" class="btn btn-secondary btn-sm">← Lista de la compra</a>
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</div>
</div>
<p class="subtitle" id="subtitulo">Cargando estadísticas...</p>
<div id="estado-carga">⏳ Cargando datos…</div>
<div id="contenido" style="display:none;">
<!-- Tarjetas métricas -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-val" id="m-medio"></div>
<div class="metric-lbl">Media mensual</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-actual"></div>
<div class="metric-lbl">Mes en curso</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-anterior"></div>
<div class="metric-lbl">Mes anterior</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-anual"></div>
<div class="metric-lbl">Últimos 12 meses</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-tickets"></div>
<div class="metric-lbl">Tickets procesados</div>
</div>
<div class="metric-card">
<div class="metric-val" id="m-productos"></div>
<div class="metric-lbl">Productos únicos</div>
</div>
</div>
<!-- Gráfico de barras mensual -->
<div class="chart-section">
<h2>Gasto mensual (€)</h2>
<div class="bar-chart" id="barChart"></div>
</div>
<!-- Tablas top -->
<div class="tables-grid">
<div class="table-section">
<h2>Top 10 por gasto total</h2>
<table class="top-table" id="tablaGasto">
<thead><tr>
<th class="rank">#</th>
<th>Producto</th>
<th class="num" style="text-align:right">€ total</th>
<th class="num" style="text-align:right">Veces</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<div class="table-section">
<h2>Top 10 más comprados</h2>
<table class="top-table" id="tablaFrecuencia">
<thead><tr>
<th class="rank">#</th>
<th>Producto</th>
<th class="num" style="text-align:right">Veces</th>
<th class="num" style="text-align:right">€ total</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div><!-- #contenido -->
<script>
const fmt = v => v.toFixed(2).replace('.', ',') + ' €';
const fmtn = v => v.toFixed(2).replace('.', ',');
window.addEventListener('load', async () => {
try {
const res = await fetch('/api/estadisticas');
if (res.status === 401) { location.href = '/login'; return; }
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
document.getElementById('estado-carga').style.display = 'none';
document.getElementById('contenido').style.display = 'block';
document.getElementById('subtitulo').textContent = 'Basado en ' + d.total_tickets + ' tickets';
// Tarjetas
document.getElementById('m-medio').textContent = fmt(d.gasto_medio_mensual);
document.getElementById('m-actual').textContent = fmt(d.gasto_mes_actual);
document.getElementById('m-anterior').textContent = fmt(d.gasto_mes_anterior);
document.getElementById('m-anual').textContent = fmt(d.gasto_anual);
document.getElementById('m-tickets').textContent = d.total_tickets;
document.getElementById('m-productos').textContent= d.productos_unicos;
// Colorear mes actual si es mayor que la media
if (d.gasto_mes_actual > d.gasto_medio_mensual * 1.1) {
document.getElementById('m-actual').style.color = '#f97316';
} else if (d.gasto_mes_actual > 0 && d.gasto_mes_actual < d.gasto_medio_mensual * 0.9) {
document.getElementById('m-actual').style.color = '#22c55e';
}
// Gráfico de barras
const meses = d.meses || [];
const maxVal = Math.max(...meses.map(m => m.gasto), 1);
const chart = document.getElementById('barChart');
chart.innerHTML = '';
meses.forEach(m => {
const pct = Math.round((m.gasto / maxVal) * 100);
const col = document.createElement('div');
col.className = 'bar-col';
col.innerHTML =
'<div class="bar-tooltip">' + fmtn(m.gasto) + ' €</div>' +
'<div class="bar-fill" style="height:' + pct + '%"></div>' +
'<span class="bar-label">' + m.label + '</span>';
chart.appendChild(col);
});
// Tabla gasto
const tbGasto = document.querySelector('#tablaGasto tbody');
const maxGasto = d.top_productos_gasto[0]?.gasto_total || 1;
d.top_productos_gasto.forEach((p, i) => {
const pct = Math.round((p.gasto_total / maxGasto) * 100);
const tr = document.createElement('tr');
tr.innerHTML =
'<td class="rank">' + (i+1) + '</td>' +
'<td><div class="prod-name" title="' + p.producto + '">' + p.producto + '</div>' +
' <div class="bar-inline" style="width:' + pct + '%"></div></td>' +
'<td class="num">' + fmtn(p.gasto_total) + ' €</td>' +
'<td class="num">' + p.veces + '</td>';
tbGasto.appendChild(tr);
});
// Tabla frecuencia
const tbFreq = document.querySelector('#tablaFrecuencia tbody');
const maxVeces = d.top_productos_frecuencia[0]?.veces_comprado || 1;
d.top_productos_frecuencia.forEach((p, i) => {
const pct = Math.round((p.veces_comprado / maxVeces) * 100);
const tr = document.createElement('tr');
tr.innerHTML =
'<td class="rank">' + (i+1) + '</td>' +
'<td><div class="prod-name" title="' + p.producto + '">' + p.producto + '</div>' +
' <div class="bar-inline" style="width:' + pct + '%"></div></td>' +
'<td class="num">' + p.veces_comprado + '</td>' +
'<td class="num">' + fmtn(p.gasto_total) + ' €</td>';
tbFreq.appendChild(tr);
});
} catch(e) {
document.getElementById('estado-carga').textContent = '❌ Error al cargar estadísticas: ' + e.message;
}
});
</script>
</body>
</html>

View File

@ -14,6 +14,7 @@
{% if session.get('usuario') == 'admin' %}
<a href="/admin/usuarios" class="btn btn-secondary btn-sm">Usuarios</a>
{% endif %}
<a href="/estadisticas" class="btn btn-secondary btn-sm">📊 Estadísticas</a>
<span style="font-size:.85rem; color:var(--text-muted);">{{ nombre }}</span>
<a href="/logout" class="btn btn-secondary btn-sm">Salir</a>
</div>