Amplia el dashboard de Home: KPIs, saldos, comparativa y movimientos

home() (views.py) y expenses/home.html pasan de mostrar tres numeros
sueltos y un listado plano de ultimos gastos a un panel mas completo:

- Saldo total y desglose por cuenta (account_balances/total_balance),
  calculado con dos queries agregadas (values().annotate(Sum(...)))
  en vez de N+1 llamadas a account.current_balance() en un bucle.
- Comparativa del gasto del mes en curso contra el mes anterior
  (prev_total/diff_amount/diff_pct), con aviso de que el mes en curso
  esta incompleto y la comparacion no es equivalente.
- Avisos (has_alerts) cuando hay objetivos excedidos o cuentas en
  negativo, con enlace directo a objetivos/cuentas.
- "Ultimos movimientos" mezcla gastos e ingresos (antes solo se veian
  los ultimos gastos) ordenados por fecha, con icono/color segun tipo.
- Se reutiliza una unica query de Goal tanto para el widget de
  objetivos (show_on_home) como para las alertas de excedidos
  (is_exceeded()), en vez de dos queries separadas.

base.css: nuevo bloque "/* Home widgets */" con las clases que usa la
plantilla (.home-section, .kpi-grid/.kpi-card, .balance-*,
.movements-list/.movement-*, .amount-positive/.amount-negative,
.comparison), incluyendo un ajuste responsive que oculta la cuenta en
movil (<480px).

No relacionado con los formularios de los commits anteriores; se
incluye aqui porque estaba pendiente de subir en la misma rama.
This commit is contained in:
JKuijperM 2026-09-03 18:01:01 +02:00
parent 5852941136
commit b04a8db080
3 changed files with 345 additions and 59 deletions

View File

@ -909,6 +909,115 @@ tr:hover {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* ========================= */
/* Home widgets */
/* ========================= */
.home-section {
margin-bottom: 2.5rem;
}
.home-section > h2 {
margin-bottom: 0.75rem;
}
.section-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
}
.balance-total {
font-size: 2rem;
font-weight: 600;
color: var(--color-success-accent);
margin: 0 0 1rem;
}
.balance-total.negative,
.balance-amount.negative {
color: var(--color-danger-accent);
}
.balance-breakdown {
list-style: none;
margin: 0;
padding: 0;
max-width: 420px;
}
.balance-breakdown li {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.balance-amount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.movements-list {
list-style: none;
margin: 0;
padding: 0;
}
.movement {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
}
.movement-date {
flex-shrink: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.movement-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.movement-account {
flex-shrink: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.movement-amount {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.amount-positive {
color: var(--color-success-accent);
}
.amount-negative {
color: var(--color-danger-accent);
}
.comparison {
margin-top: 1rem;
}
@media (max-width: 480px) {
.movement-account {
display: none;
}
}
/* ========================= */ /* ========================= */
/* Responsive */ /* Responsive */
/* ========================= */ /* ========================= */

View File

@ -8,29 +8,111 @@
<h1>Home</h1> <h1>Home</h1>
<section> {% if has_alerts %}
<h2>Resumen del mes</h2> <div class="messages">
<p>Total: {{ kpi_total|floatformat:2 }}</p> {% for goal in exceeded_goals %}
<p>Gastos: {{ kpi_count }}</p> <div class="message warning">
<p>Categorías: {{ kpi_categories }}</p> Presupuesto <strong>{{ goal.name }}</strong> excedido:
</section> {{ goal.progress|floatformat:2 }}€ de {{ goal.target_amount|floatformat:2 }}€.
<a href="{% url 'goal_list' %}">Ver objetivos</a>
</div>
{% endfor %}
{% for item in negative_accounts %}
<div class="message error">
La cuenta <strong>{{ item.account.name }}</strong> está en negativo:
{{ item.balance|floatformat:2 }}€.
<a href="{% url 'account_list' %}">Ver cuentas</a>
</div>
{% endfor %}
</div>
{% endif %}
<section> <section class="home-section">
<h2>Últimos gastos</h2> <h2>Saldo total</h2>
<ul> <p class="balance-total {% if total_balance < 0 %}negative{% endif %}">
{% for expense in last_expenses %} {{ total_balance|floatformat:2 }}€
</p>
<ul class="balance-breakdown">
{% for item in account_balances %}
<li> <li>
{{ expense.date }} - <span class="balance-account">{{ item.account.name }}</span>
{{ expense.category.name }} - <span class="balance-amount {% if item.balance < 0 %}negative{% endif %}">
{{ expense.amount }} {{ item.balance|floatformat:2 }}€
</span>
</li> </li>
{% empty %} {% empty %}
<li>No hay gastos</li> <li class="muted">No tienes cuentas activas.</li>
{% endfor %} {% endfor %}
</ul> </ul>
</section> </section>
<section> <section class="home-section">
<h2>Resumen del mes</h2>
<div class="kpi-grid">
<div class="kpi-card">
<span class="kpi-label">Gastado este mes</span>
<span class="kpi-value">{{ kpi_total|floatformat:2 }}€</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Nº de gastos</span>
<span class="kpi-value">{{ kpi_count }}</span>
</div>
<div class="kpi-card">
<span class="kpi-label">Categorías usadas</span>
<span class="kpi-value">{{ kpi_categories }}</span>
</div>
</div>
<p class="comparison">
{% if diff_pct is None %}
<span class="muted">Sin gastos el mes pasado para comparar.</span>
{% else %}
Mes anterior: {{ prev_total|floatformat:2 }}€ —
{% if diff_amount > 0 %}
<span class="amount-negative">
+{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%)
</span>
{% elif diff_amount < 0 %}
<span class="amount-positive">
{{ diff_amount|floatformat:2 }}€ ({{ diff_pct|floatformat:1 }}%)
</span>
{% else %}
sin variación
{% endif %}
{% endif %}
<br>
<small class="muted">
El mes en curso está incompleto: la comparación no es equivalente
hasta que termine.
</small>
</p>
</section>
<section class="home-section">
<h2>Últimos movimientos</h2>
<ul class="movements-list">
{% for mov in movements %}
<li class="movement">
<span class="movement-date">{{ mov.date }}</span>
<span class="movement-label">{{ mov.label }}</span>
<span class="movement-account muted">{{ mov.account }}</span>
<span class="movement-amount {% if mov.kind == 'income' %}amount-positive{% else %}amount-negative{% endif %}">
{% if mov.kind == 'income' %}+{% else %}{% endif %}{{ mov.amount|floatformat:2 }}€
</span>
</li>
{% empty %}
<li class="muted">Todavía no hay movimientos.</li>
{% endfor %}
</ul>
<div class="section-actions">
<a class="btn btn-secondary" href="{% url 'expense_list' %}">Ver todos los gastos</a>
<a class="btn btn-secondary" href="{% url 'income_list' %}">Ver todos los ingresos</a>
</div>
</section>
<section class="home-section">
<div class="dashboard-grid"> <div class="dashboard-grid">
<div class="card card-chart"> <div class="card card-chart">
<h2>Últimos meses</h2> <h2>Últimos meses</h2>
@ -79,32 +161,39 @@
applyCartesianColors(chart, colors); applyCartesianColors(chart, colors);
}); });
</script> </script>
</section> </section>
<h3>Objetivos</h3> <section class="home-section">
{% if goals %} <h2>Objetivos</h2>
<div class="goals-widget"> {% if goals %}
{% for goal in goals %} <div class="goals-widget">
<div class="goal-card"> {% for goal in goals %}
<strong>{{ goal.name }}</strong> <div class="goal-card">
{% if goal.is_exceeded %} <strong>{{ goal.name }}</strong>
<span class="badge badge-inactive">Excedido</span> {% if goal.is_exceeded %}
{% endif %} <span class="badge badge-inactive">Excedido</span>
{% endif %}
<div class="progress-bar"> <div class="progress-bar"
<div class="progress-fill {{ goal.progress_state }}" role="progressbar"
style="width: {{ goal.bar_width|unlocalize }}%"></div> aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ goal.bar_width|unlocalize }}"
aria-label="Progreso de {{ goal.name }}"
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
</span>
</div> </div>
{% endfor %}
<span class="progress-label"> </div>
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%) {% else %}
</span> <p class="muted">No tienes objetivos aún.</p>
</div> {% endif %}
{% endfor %} </section>
</div>
{% else %}
<p>No tienes objetivos aún.</p>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,7 @@
import logging import logging
import calendar import calendar
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
from .forms import ( from .forms import (
@ -58,21 +59,108 @@ def sub_months(year, month, n):
@login_required @login_required
def home(request): def home(request):
today = date.today()
expenses = Expense.objects.filter(owner=request.user) expenses = Expense.objects.filter(owner=request.user)
# Last expenses # ---- KPIs del mes en curso ----
last_expenses = expenses.select_related("category").order_by("-date")[:5]
# Simple KPIs (current month)
today = date.today()
month_expenses = expenses.filter(date__year=today.year, date__month=today.month) month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or 0
kpi_count = month_expenses.count() kpi_count = month_expenses.count()
kpi_categories = month_expenses.values("category").distinct().count() kpi_categories = month_expenses.values("category").distinct().count()
# ---- Comparativa con el mes anterior ----
prev_year, prev_month = sub_months(today.year, today.month, 1)
prev_total = (
expenses.filter(date__year=prev_year, date__month=prev_month).aggregate(
total=Sum("amount")
)["total"]
or Decimal("0")
)
diff_amount = kpi_total - prev_total
# None cuando no hay mes anterior con gastos (evita división por cero)
diff_pct = None
if prev_total:
diff_pct = (diff_amount / prev_total) * 100
# ---- Saldos de cuentas ----
# Dos queries agregadas en lugar de N+1 (nada de account.current_balance() en bucle).
accounts = list(Account.objects.filter(owner=request.user, active=True))
account_ids = [a.id for a in accounts]
expense_totals = {
row["account_id"]: row["total"]
for row in Expense.objects.filter(account_id__in=account_ids)
.values("account_id")
.annotate(total=Sum("amount"))
}
income_totals = {
row["account_id"]: row["total"]
for row in Income.objects.filter(account_id__in=account_ids)
.values("account_id")
.annotate(total=Sum("amount"))
}
account_balances = []
negative_accounts = []
total_balance = Decimal("0")
for acc in accounts:
balance = (
acc.initial_balance
+ (income_totals.get(acc.id) or Decimal("0"))
- (expense_totals.get(acc.id) or Decimal("0"))
)
total_balance += balance
account_balances.append({"account": acc, "balance": balance})
if balance < 0:
negative_accounts.append({"account": acc, "balance": balance})
# ---- Objetivos ----
# Una sola query reutilizada para "goals" (home) y "exceeded_goals" (alertas).
# is_exceeded() solo aplica a kind == budget, así que pago y ahorro nunca la disparan.
all_goals = list(
Goal.objects.filter(owner=request.user).select_related("category", "account")
)
goals = [g for g in all_goals if g.show_on_home]
exceeded_goals = [g for g in all_goals if g.is_exceeded()]
has_alerts = bool(exceeded_goals or negative_accounts)
# ---- Últimos movimientos (gastos + ingresos mezclados) ----
recent_expenses = expenses.select_related("category", "account").order_by(
"-date", "-id"
)[:8]
recent_incomes = (
Income.objects.filter(owner=request.user)
.select_related("account")
.order_by("-date", "-id")[:8]
)
movements = sorted(
[
{
"kind": "expense",
"date": e.date,
"label": e.category.name,
"account": e.account.name,
"amount": e.amount,
}
for e in recent_expenses
]
+ [
{
"kind": "income",
"date": i.date,
"label": i.name,
"account": i.account.name,
"amount": i.amount,
}
for i in recent_incomes
],
key=lambda m: m["date"],
reverse=True,
)[:8]
# ---- Mini-gráfico de 6 meses (sin cambios) ----
six_months = [] six_months = []
for i in range(5, -1, -1): for i in range(5, -1, -1):
y, m = sub_months(today.year, today.month, i) y, m = sub_months(today.year, today.month, i)
@ -86,28 +174,28 @@ def home(request):
] ]
or 0 or 0
) )
mini_data.append({"label": f"{m}/{y}", "total": float(total)})
mini_data.append(
{
"label": f"{m}/{y}",
"total": float(total),
}
)
goals = Goal.objects.filter(owner=request.user, show_on_home=True)
return render( return render(
request, request,
"expenses/home.html", "expenses/home.html",
{ {
"active_menu": "home", "active_menu": "home",
"last_expenses": last_expenses,
"kpi_total": kpi_total, "kpi_total": kpi_total,
"kpi_count": kpi_count, "kpi_count": kpi_count,
"kpi_categories": kpi_categories, "kpi_categories": kpi_categories,
"prev_total": prev_total,
"diff_amount": diff_amount,
"diff_pct": diff_pct,
"total_balance": total_balance,
"account_balances": account_balances,
"negative_accounts": negative_accounts,
"goals": goals,
"exceeded_goals": exceeded_goals,
"has_alerts": has_alerts,
"movements": movements,
"mini_chart_labels": [x["label"] for x in mini_data], "mini_chart_labels": [x["label"] for x in mini_data],
"mini_chart_data": [x["total"] for x in mini_data], "mini_chart_data": [x["total"] for x in mini_data],
"goals": goals,
}, },
) )