Añadidas tandas: 4, 4b y 7 #33

Merged
jkuijperm merged 13 commits from dev into main 2026-09-09 14:29:29 +00:00
3 changed files with 345 additions and 59 deletions
Showing only changes of commit b04a8db080 - Show all commits

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,10 +161,10 @@
applyCartesianColors(chart, colors); applyCartesianColors(chart, colors);
}); });
</script> </script>
</section> </section>
<h3>Objetivos</h3> <section class="home-section">
<h2>Objetivos</h2>
{% if goals %} {% if goals %}
<div class="goals-widget"> <div class="goals-widget">
{% for goal in goals %} {% for goal in goals %}
@ -92,7 +174,13 @@
<span class="badge badge-inactive">Excedido</span> <span class="badge badge-inactive">Excedido</span>
{% endif %} {% endif %}
<div class="progress-bar"> <div class="progress-bar"
role="progressbar"
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 }}" <div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div> style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div> </div>
@ -104,7 +192,8 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p>No tienes objetivos aún.</p> <p class="muted">No tienes objetivos aún.</p>
{% endif %} {% endif %}
</section>
{% 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,
}, },
) )