Añadidas tandas: 4, 4b y 7 #33
@ -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 */
|
||||||
/* ========================= */
|
/* ========================= */
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user