Compare commits

..

No commits in common. "b04a8db080e3bdb79bb63fa8c0af3dbbaf217189" and "e29c2bc5f2eaa86c148947f0e2eda7140e9f9ae9" have entirely different histories.

16 changed files with 129 additions and 489 deletions

View File

@ -694,59 +694,10 @@ tr:hover {
/* Forms */
/* ========================= */
.app-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 480px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
.form-field label {
font-weight: 600;
font-size: 0.9rem;
}
.form-field input:not([type="checkbox"]):not([type="radio"]),
.form-field select,
.form-field textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
border-color: var(--color-primary);
}
.form-field-checkbox {
flex-direction: row;
align-items: center;
}
.form-field-checkbox input[type="checkbox"] {
accent-color: var(--color-primary);
}
.form-help {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.form-errors {
color: var(--color-danger-accent);
font-size: 0.85rem;
@ -909,115 +860,6 @@ 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

@ -7,9 +7,9 @@
{% block content %}
<h2>Editar categoría</h2>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>
<a class="btn btn-secondary" href="{% url 'category_list' %}">Cancelar</a>

View File

@ -9,9 +9,9 @@
<h1>Mis categorías</h1>
<h3> Nueva categoría</h3>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Crear</button>
</form>
@ -24,7 +24,6 @@
<tr>
<th>Categoría</th>
<th>Categoría padre</th>
<th></th>
</tr>
</thead>
<tbody>

View File

@ -1,30 +0,0 @@
{% for field in form %}
<div class="form-field {% if field.field.widget.input_type == 'checkbox' %}form-field-checkbox{% endif %}">
{% if field.name == "tags" %}
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}
<label class="tag-chip">
{{ checkbox.tag }}
<span>{{ checkbox.choice_label }}</span>
</label>
{% endfor %}
</div>
{% else %}
{{ field.label_tag }}
{{ field }}
{% endif %}
{% if field.help_text %}
<small class="form-help">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="form-errors">{{ field.errors }}</div>
{% endif %}
</div>
{% endfor %}
{% if form.non_field_errors %}
<div class="form-errors">{{ form.non_field_errors }}</div>
{% endif %}

View File

@ -10,9 +10,9 @@
{% endif %}
</h1>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>

View File

@ -17,10 +17,34 @@
{% endif %}
</h1>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{% for field in form %}
{% if field.name == "tags" %}
<div class="form-field">
{{ field.label_tag }}
<div class="tag-chip-list">
{% for checkbox in field %}
<label class="tag-chip">
{{ checkbox.tag }}
<span>{{ checkbox.choice_label }}</span>
</label>
{% endfor %}
</div>
{% if field.errors %}
<div class="form-errors">{{ field.errors }}</div>
{% endif %}
</div>
{% else %}
<p>
{{ field.label_tag }} {{ field }}
{% if field.errors %}
<span class="form-errors">{{ field.errors }}</span>
{% endif %}
</p>
{% endif %}
{% endfor %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">

View File

@ -8,111 +8,29 @@
<h1>Home</h1>
{% 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 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>
<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 class="muted">No tienes cuentas activas.</li>
{% endfor %}
</ul>
</section>
<section class="home-section">
<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>
<p>Total: {{ kpi_total|floatformat:2 }}</p>
<p>Gastos: {{ kpi_count }}</p>
<p>Categorías: {{ kpi_categories }}</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>
<section>
<h2>Últimos gastos</h2>
<ul>
{% for expense in last_expenses %}
<li>
{{ expense.date }} -
{{ expense.category.name }} -
{{ expense.amount }}
</li>
{% empty %}
<li class="muted">Todavía no hay movimientos.</li>
<li>No hay gastos</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">
<section>
<div class="dashboard-grid">
<div class="card card-chart">
<h2>Últimos meses</h2>
@ -161,10 +79,10 @@
applyCartesianColors(chart, colors);
});
</script>
</section>
<section class="home-section">
<h2>Objetivos</h2>
<h3>Objetivos</h3>
{% if goals %}
<div class="goals-widget">
{% for goal in goals %}
@ -174,13 +92,7 @@
<span class="badge badge-inactive">Excedido</span>
{% endif %}
<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-bar">
<div class="progress-fill {{ goal.progress_state }}"
style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div>
@ -192,8 +104,7 @@
{% endfor %}
</div>
{% else %}
<p class="muted">No tienes objetivos aún.</p>
<p>No tienes objetivos aún.</p>
{% endif %}
</section>
{% endblock %}

View File

@ -16,9 +16,9 @@
{% endif %}
</h1>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if form.instance.pk %}

View File

@ -3,9 +3,9 @@
{% block content %}
<h1>Etiqueta</h1>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Guardar</button>
<a class="btn btn-secondary" href="{% url 'tag_list' %}">Volver</a>

View File

@ -17,10 +17,10 @@
{% endif %}
</h1>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if editing %}

View File

@ -1,16 +1,16 @@
{% extends "expenses/base.html" %}
{% block title %}
{{ title }}
Nuevo objetivo
{% endblock %}
{% block content %}
<h1>
{{ title }}
Nuevo objetivo
</h1>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Guardar

View File

@ -4,9 +4,9 @@
<h2>Iniciar sesión</h2>
<form method="post" class="app-form">
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Entrar</button>
</form>

View File

@ -1,13 +1,6 @@
{% extends "expenses/base.html" %}
{% block title %}Contraseña actualizada{% endblock %}
{% extends "base.html" %}
{% block content %}
<h1>Contraseña actualizada</h1>
<p>Tu contraseña se ha cambiado correctamente.</p>
<div class="form-actions">
<a class="btn btn-primary" href="{% url 'home' %}">Volver al inicio</a>
</div>
<h2>Contraseña actualizada correctamente</h2>
<a href="/">Volver</a>
{% endblock %}

View File

@ -1,16 +1,10 @@
{% extends "expenses/base.html" %}
{% block title %}Cambiar contraseña{% endblock %}
{% extends "base.html" %}
{% block content %}
<h1>Cambiar contraseña</h1>
<form method="post" class="app-form">
<h2>Cambiar contraseña</h2>
<form method="post">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">Cambiar contraseña</button>
<a class="btn btn-secondary" href="{% url 'home' %}">Volver</a>
</div>
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Cambiar</button>
</form>
{% endblock %}

View File

@ -1,7 +1,6 @@
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 (
@ -59,108 +58,21 @@ def sub_months(year, month, n):
@login_required
def home(request):
today = date.today()
expenses = Expense.objects.filter(owner=request.user)
# ---- KPIs del mes en curso ----
# Last expenses
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)
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_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)
@ -174,28 +86,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,
},
)

View File

@ -61,19 +61,14 @@ if not DEBUG:
# Application definition
# 'expenses' va antes que 'django.contrib.admin' a proposito: el cargador de
# plantillas por aplicacion recorre esta lista en orden y se queda con la
# primera coincidencia. Si el admin va antes, sus plantillas de registration/
# sombrean a las nuestras (paso con password_change_form.html y
# password_change_done.html). No reordenar alfabeticamente.
INSTALLED_APPS = [
'expenses',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'expenses',
]
MIDDLEWARE = [