feat: pagina de estadisticas de consumo (gasto mensual, top productos, graficos)
This commit is contained in:
parent
02b055a96b
commit
3d31d308e5
86
app.py
86
app.py
|
|
@ -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
|
||||
# -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue