Anade filtro por tipo en objetivos y unifica la barra de progreso

El listado de objetivos mezclaba pagos, presupuestos y ahorros sin forma de
separarlos. Anade un filtro por tipo, con el mismo patron que los filtros de
gastos, y ordena los objetivos por nombre. Un valor de "kind" que no este en
KIND_CHOICES se ignora en vez de romper.

De paso, la barra de progreso pasa a usar goal.progress_state en lugar de
decidir el color en la plantilla con un if sobre el porcentaje. La logica ya
vivia en el modelo, que ademas distingue presupuestos (donde acercarse al
100% es un aviso) del resto; la plantilla del dashboard seguia con las clases
antiguas low/medium/high, que se retiran del CSS.

Incluye estado vacio y thead/tbody en la tabla, que le faltaban.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-09 16:16:58 +02:00
parent 22563df48a
commit e814bc2d03
5 changed files with 123 additions and 55 deletions

View File

@ -367,8 +367,23 @@ button[type="submit"]:hover,
.filters-main { .filters-main {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem;
align-items: center; align-items: center;
gap: 0.5rem;
}
.filters-main select {
padding: 0.5rem 0.7rem;
font-family: inherit;
font-size: 0.95rem;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.filters-main select:focus {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
} }
.filters-advanced { .filters-advanced {
@ -826,17 +841,14 @@ tr:hover {
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
} }
.progress-fill.low,
.progress-fill.danger { .progress-fill.danger {
background-color: var(--color-danger-accent); background-color: var(--color-danger-accent);
} }
.progress-fill.medium,
.progress-fill.warning { .progress-fill.warning {
background-color: var(--color-warning-accent); background-color: var(--color-warning-accent);
} }
.progress-fill.high,
.progress-fill.complete { .progress-fill.complete {
background-color: var(--color-success-accent); background-color: var(--color-success-accent);
} }

View File

@ -428,11 +428,7 @@
aria-valuenow="{{ goal.percentage|unlocalize }}" aria-valuenow="{{ goal.percentage|unlocalize }}"
aria-label="Progreso de {{ goal.name }}" aria-label="Progreso de {{ goal.name }}"
aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)"> aria-valuetext="{{ goal.progress|floatformat:1 }}€ de {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)">
<div class="progress-fill <div class="progress-fill {{ goal.progress_state }}"
{% if goal.percentage < 50 %} low
{% elif goal.percentage < 80 %} medium
{% else %} high
{% endif %}"
style="width: {{ goal.bar_width|unlocalize }}%"></div> style="width: {{ goal.bar_width|unlocalize }}%"></div>
</div> </div>

View File

@ -9,56 +9,85 @@
{% block content %} {% block content %}
<h1>Objetivos</h1> <h1>Objetivos</h1>
<a class="btn btn-primary" href="{% url 'goal_create' %}"> Nuevo objetivo</a> <div class="section-actions">
<a class="btn btn-primary" href="{% url 'goal_create' %}"> Nuevo objetivo</a>
</div>
<form method="get" class="filters">
<div class="filters-main">
<label class="sr-only" for="filterKind">Tipo</label>
<select name="kind" id="filterKind" onchange="this.form.submit()">
<option value="">Todos los tipos</option>
{% for value, label in kind_choices %}
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Filtrar</button>
<a href="{% url 'goal_list' %}" class="btn btn-secondary">Limpiar</a>
</div>
</form>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<tr> <thead>
<th>Nombre</th> <tr>
<th>Tipo</th> <th>Nombre</th>
<th>Progreso</th> <th>Tipo</th>
<th></th> <th>Progreso</th>
</tr> <th></th>
</tr>
{% for goal in goals %} </thead>
<tr> <tbody>
<td>{{ goal.name }}</td> {% for goal in goals %}
<td> <tr>
{{ goal.get_kind_display }} <td>{{ goal.name }}</td>
{% if goal.kind == "budget" %} <td>
<small>({{ goal.get_period_display|lower }})</small> {{ goal.get_kind_display }}
{% endif %} {% if goal.kind == "budget" %}
</td> <small>({{ goal.get_period_display|lower }})</small>
<td>
<div class="progress-container">
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
</span>
<div class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ goal.percentage|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.percentage|floatformat:1 }}%
</span>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %} {% endif %}
</div> </td>
</td> <td>
<div class="progress-container">
<span class="progress-label">
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
</span>
<div class="progress-bar"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="{{ goal.percentage|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.percentage|floatformat:1 }}%
</span>
{% if goal.is_exceeded %}
<span class="badge badge-inactive">Excedido</span>
{% endif %}
</div>
</td>
<td> <td class="table-actions">
<a href="{% url 'goal_edit' goal.id %}">Editar</a> <a href="{% url 'goal_edit' goal.id %}">Editar</a>
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a> <a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
</td> </td>
</tr> </tr>
{% endfor %} {% empty %}
<tr>
<td colspan="4" class="empty-state">
<p>No hay objetivos</p>
<a href="{% url 'goal_create' %}">Añade el primero</a>
</td>
</tr>
{% endfor %}
</tbody>
</table> </table>
</div> </div>

View File

@ -1,6 +1,7 @@
import pytest import pytest
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from django.urls import reverse
from expenses.models import Account, Category, Expense, Goal from expenses.models import Account, Category, Expense, Goal
from expenses.forms import GoalForm from expenses.forms import GoalForm
@ -194,3 +195,23 @@ def test_goalform_valid_payment_saves_goal(user, category):
goal.save() goal.save()
assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists() assert Goal.objects.filter(pk=goal.pk, name="Ahorro viaje", kind=Goal.KIND_PAYMENT, category=category).exists()
def test_goal_list_filters_by_kind(auth_client, user, category):
Goal.objects.create(owner=user, name="Pago", target_amount=Decimal("100"), kind=Goal.KIND_PAYMENT, category=category)
Goal.objects.create(owner=user, name="Presupuesto", target_amount=Decimal("200"), kind=Goal.KIND_BUDGET, category=category, period=Goal.PERIOD_MONTH)
response = auth_client.get(reverse('goal_list'), {"kind": Goal.KIND_BUDGET})
assert [g.name for g in response.context["goals"]] == ["Presupuesto"]
assert response.context["selected_kind"] == Goal.KIND_BUDGET
def test_goal_list_ignores_invalid_kind_param(auth_client, user, category):
Goal.objects.create(owner=user, name="Pago", target_amount=Decimal("100"), kind=Goal.KIND_PAYMENT, category=category)
response = auth_client.get(reverse('goal_list'), {"kind": "bogus"})
assert response.status_code == 200
assert [g.name for g in response.context["goals"]] == ["Pago"]
assert response.context["selected_kind"] == ""

View File

@ -1071,11 +1071,21 @@ def category_delete(request, pk):
def goal_list(request): def goal_list(request):
goals = Goal.objects.filter(owner=request.user) goals = Goal.objects.filter(owner=request.user)
selected_kind = request.GET.get("kind") or ""
if selected_kind in dict(Goal.KIND_CHOICES):
goals = goals.filter(kind=selected_kind)
else:
selected_kind = ""
goals = goals.order_by("name")
return render( return render(
request, request,
"goals/list.html", "goals/list.html",
{ {
"goals": goals, "goals": goals,
"kind_choices": Goal.KIND_CHOICES,
"selected_kind": selected_kind,
"active_menu": "settings", "active_menu": "settings",
}, },
) )