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);
}
/* ========================= */
/* 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 */
/* ========================= */

View File

@ -8,29 +8,111 @@
<h1>Home</h1>
<section>
<h2>Resumen del mes</h2>
<p>Total: {{ kpi_total|floatformat:2 }}</p>
<p>Gastos: {{ kpi_count }}</p>
<p>Categorías: {{ kpi_categories }}</p>
</section>
{% if has_alerts %}
<div class="messages">
{% for goal in exceeded_goals %}
<div class="message warning">
Presupuesto <strong>{{ goal.name }}</strong> excedido:
{{ 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>
<h2>Últimos gastos</h2>
<ul>
{% for expense in last_expenses %}
<section class="home-section">
<h2>Saldo total</h2>
<p class="balance-total {% if total_balance < 0 %}negative{% endif %}">
{{ total_balance|floatformat:2 }}€
</p>
<ul class="balance-breakdown">
{% for item in account_balances %}
<li>
{{ expense.date }} -
{{ expense.category.name }} -
{{ expense.amount }}
<span class="balance-account">{{ item.account.name }}</span>
<span class="balance-amount {% if item.balance < 0 %}negative{% endif %}">
{{ item.balance|floatformat:2 }}€
</span>
</li>
{% empty %}
<li>No hay gastos</li>
<li class="muted">No tienes cuentas activas.</li>
{% endfor %}
</ul>
</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="card card-chart">
<h2>Últimos meses</h2>
@ -43,7 +125,7 @@
<script>
const labels = {{ mini_chart_labels|safe }};
const data = {{ mini_chart_data|safe }};
const ctx = document.getElementById('miniChart');
const miniChartColors = getChartColors();
@ -79,32 +161,39 @@
applyCartesianColors(chart, colors);
});
</script>
</section>
<h3>Objetivos</h3>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<section class="home-section">
<h2>Objetivos</h2>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
<div class="goal-card">
<strong>{{ goal.name }}</strong>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<div class="progress-bar">
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
<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 }}"
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>
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
</span>
</div>
{% endfor %}
</div>
{% else %}
<p>No tienes objetivos aún.</p>
{% endif %}
{% endfor %}
</div>
{% else %}
<p class="muted">No tienes objetivos aún.</p>
{% endif %}
</section>
{% endblock %}

View File

@ -1,6 +1,7 @@
import logging
import calendar
from datetime import date, datetime
from decimal import Decimal
from django.contrib import messages
from .models import Account, Category, Expense, FuelEntry, Tag, Income, Goal
from .forms import (
@ -58,21 +59,108 @@ def sub_months(year, month, n):
@login_required
def home(request):
today = date.today()
expenses = Expense.objects.filter(owner=request.user)
# Last expenses
last_expenses = expenses.select_related("category").order_by("-date")[:5]
# Simple KPIs (current month)
today = date.today()
# ---- KPIs del mes en curso ----
month_expenses = expenses.filter(date__year=today.year, date__month=today.month)
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or 0
kpi_total = month_expenses.aggregate(total=Sum("amount"))["total"] or Decimal("0")
kpi_count = month_expenses.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 = []
for i in range(5, -1, -1):
y, m = sub_months(today.year, today.month, i)
@ -86,28 +174,28 @@ def home(request):
]
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(
request,
"expenses/home.html",
{
"active_menu": "home",
"last_expenses": last_expenses,
"kpi_total": kpi_total,
"kpi_count": kpi_count,
"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_data": [x["total"] for x in mini_data],
"goals": goals,
},
)