feat: CRUD de traspasos

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JKuijperM 2026-09-22 08:51:16 +02:00
parent 9af6e749d5
commit 0dd34ab5dc
8 changed files with 238 additions and 6 deletions

View File

@ -33,6 +33,7 @@
<a href="{% url 'dashboard' %}" class="nav-item {% if active_menu == 'dashboard' %}active{% endif %} ">Dashboard</a>
<a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a>
<a href="{% url 'income_list' %}" class="nav-item {% if active_menu == 'incomes' %}active{% endif %} ">Ingresos</a>
<a href="{% url 'transfer_list' %}" class="nav-item {% if active_menu == 'transfers' %}active{% endif %} ">Traspasos</a>
<a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a>
<a href="{% url 'account_list' %}" class="nav-item {% if active_menu == 'accounts' %}active{% endif %} ">Cuentas</a>
<div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}" id="settingsDropdown">

View File

@ -145,7 +145,10 @@
{% endfor %}
</td>
<td class="table-actions">
{% if expense.fuel_data %}
{% if expense.transfer %}
{# Pata de un traspaso: se edita y se borra desde el traspaso. #}
<a href="{% url 'transfer_edit' expense.transfer.id %}">Editar traspaso</a>
{% elif expense.fuel_data %}
<a href="{% url 'fuel_edit' expense.id %}?next={{ request.get_full_path|urlencode }}">Editar</a>
<a href="{% url 'fuel_delete' expense.id %}?next={{ request.get_full_path|urlencode }}" class="danger">Eliminar</a>
{% else %}

View File

@ -93,8 +93,13 @@
<td>{{ income.amount|floatformat:2 }}</td>
<td>{{ income.date }}</td>
<td class="table-actions">
{% if income.transfer %}
{# Pata de un traspaso: se edita y se borra desde el traspaso. #}
<a href="{% url 'transfer_edit' income.transfer.id %}">Editar traspaso</a>
{% else %}
<a href="{% url 'income_edit' income.id %}">Editar</a>
<a href="{% url 'income_delete' income.id %}" class="danger">Eliminar</a>
{% endif %}
</td>
</tr>
{% empty %}

View File

@ -0,0 +1,23 @@
{% extends "expenses/_confirm_delete.html" %}
{% block title %}Traspasos{% endblock %}
{% block delete_heading %}Eliminar traspaso{% endblock %}
{% block delete_question %}
¿Seguro que quieres eliminar el traspaso de
<strong>{{ transfer.amount|floatformat:2 }}€</strong>
de <strong>{{ transfer.from_account.name }}</strong>
a <strong>{{ transfer.to_account.name }}</strong>
del {{ transfer.date }}?
{% endblock %}
{% block delete_warnings %}
<p class="form-errors">
Se eliminarán también el gasto de la cuenta de origen y el ingreso de la de
destino{% if transfer.fee_amount %}, además del gasto de la comisión{% endif %}.
Los saldos de las dos cuentas volverán a como estaban.
</p>
{% endblock %}
{% block cancel_url %}{% url 'transfer_list' %}{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "expenses/base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{% if form.instance.pk %}
<p>
Al guardar se actualizan los dos movimientos del traspaso y su comisión.
No hace falta tocarlos por separado en Gastos ni en Ingresos.
</p>
{% endif %}
<form method="post" class="app-form">
{% csrf_token %}
{% include "expenses/_form_fields.html" %}
<div class="form-actions">
<button type="submit" class="btn btn-primary">
{% if form.instance.pk %}
Guardar traspaso
{% else %}
Crear traspaso
{% endif %}
</button>
<a class="btn btn-secondary" href="{% url 'transfer_list' %}">Volver</a>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends "expenses/base.html" %}
{% block title %}
Traspasos
{% endblock %}
{% block content %}
<h1>Mis traspasos</h1>
<p>
Un traspaso mueve dinero entre dos cuentas propias: no cuenta como gasto ni
como ingreso en los análisis, pero sí cambia el saldo de las dos cuentas.
La comisión, si la hay, sí es un gasto real.
</p>
<div class="section-actions">
<a class="btn btn-primary" href="{% url 'transfer_create' %}">➕ Nuevo traspaso</a>
</div>
<br>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Fecha</th>
<th>Origen</th>
<th>Destino</th>
<th>Importe</th>
<th>Comisión</th>
<th></th>
</tr>
</thead>
<tbody>
{% for transfer in transfers %}
<tr>
<td>{{ transfer.date }}</td>
<td>{{ transfer.from_account.name }}</td>
<td>{{ transfer.to_account.name }}</td>
<td>{{ transfer.amount|floatformat:2 }}€</td>
<td>
{% if transfer.fee_amount %}
{{ transfer.fee_amount|floatformat:2 }}€
{% else %}
<span>-</span>
{% endif %}
</td>
<td class="table-actions">
<a href="{% url 'transfer_edit' transfer.id %}">Editar</a>
<a href="{% url 'transfer_delete' transfer.id %}" class="danger">Eliminar</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="empty-state">
<p>No hay traspasos</p>
<a href="{% url 'transfer_create' %}">Añade el primero</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -25,6 +25,10 @@ urlpatterns = [
path('fuel/create/', views.fuel_create, name='fuel_create'),
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
path('fuel/<int:pk>/delete/', views.fuel_delete, name='fuel_delete'),
path('transfers/', views.transfer_list, name='transfer_list'),
path('transfers/new/', views.transfer_create, name='transfer_create'),
path('transfers/<int:pk>/edit/', views.transfer_edit, name='transfer_edit'),
path('transfers/<int:pk>/delete/', views.transfer_delete, name='transfer_delete'),
path('categories/', views.category_list, name='category_list'),
path('categories/<int:pk>/edit/', views.category_edit, name='category_edit'),
path('categories/<int:pk>/delete/', views.category_delete, name='category_delete'),

View File

@ -3,7 +3,16 @@ import calendar
from datetime import date, datetime, timedelta
from decimal import Decimal
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,
Transfer,
)
from .forms import (
ExpenseForm,
IncomeForm,
@ -12,6 +21,7 @@ from .forms import (
FuelEntryForm,
CategoryForm,
GoalForm,
TransferForm,
)
from django.core.paginator import Paginator
@ -353,7 +363,11 @@ def home(request):
@login_required
def expense_list(request):
expenses = Expense.objects.filter(owner=request.user)
# Las relaciones de traspaso van en select_related porque la tabla consulta
# expense.transfer en cada fila para decidir los enlaces de acción.
expenses = Expense.objects.filter(owner=request.user).select_related(
"transfer_out", "transfer_fee"
)
categories = Category.objects.filter(owner=request.user)
@ -984,7 +998,9 @@ def income_create(request):
@login_required
def income_list(request):
incomes = Income.objects.filter(owner=request.user).select_related("account")
incomes = Income.objects.filter(owner=request.user).select_related(
"account", "transfer_in"
)
year_list = Income.objects.filter(owner=request.user).dates("date", "year")
@ -1436,3 +1452,90 @@ def goal_delete(request, pk):
@login_required
def settings_index(request):
return render(request, "settings/index.html", {"active_menu": "settings"})
@login_required
def transfer_list(request):
transfers = Transfer.objects.filter(owner=request.user).select_related(
"from_account", "to_account"
)
return render(
request,
"transfers/list.html",
{
"active_menu": "transfers",
"transfers": transfers,
},
)
@login_required
def transfer_create(request):
if request.method == "POST":
form = TransferForm(request.POST, user=request.user)
if form.is_valid():
transfer = form.save(commit=False)
transfer.owner = request.user
# sync() guarda el traspaso y crea las patas en una sola transacción.
transfer.sync()
messages.success(request, "Traspaso creado.")
return redirect("transfer_list")
else:
form = TransferForm(user=request.user)
return render(
request,
"transfers/form.html",
{
"active_menu": "transfers",
"form": form,
"title": "Nuevo traspaso",
},
)
@login_required
def transfer_edit(request, pk):
transfer = get_object_or_404(Transfer, pk=pk, owner=request.user)
if request.method == "POST":
form = TransferForm(request.POST, instance=transfer, user=request.user)
if form.is_valid():
transfer = form.save(commit=False)
transfer.sync()
messages.success(request, "Traspaso actualizado.")
return redirect("transfer_list")
else:
form = TransferForm(instance=transfer, user=request.user)
return render(
request,
"transfers/form.html",
{
"active_menu": "transfers",
"form": form,
"title": "Editar traspaso",
},
)
@login_required
def transfer_delete(request, pk):
transfer = get_object_or_404(Transfer, pk=pk, owner=request.user)
if request.method == "POST":
# Transfer.delete() se lleva también las dos patas y la comisión.
transfer.delete()
messages.success(request, "Traspaso eliminado.")
return redirect("transfer_list")
return render(
request,
"transfers/confirm_delete.html",
{"active_menu": "transfers", "transfer": transfer},
)