feat: add recurring transactions

This commit is contained in:
Herculino Trotta
2024-10-17 00:38:12 -03:00
parent ce422ce4b1
commit a18e2ab58a
15 changed files with 630 additions and 175 deletions
+8
View File
@@ -5,6 +5,7 @@ from apps.transactions.models import (
TransactionCategory, TransactionCategory,
TransactionTag, TransactionTag,
InstallmentPlan, InstallmentPlan,
RecurringTransaction,
) )
@@ -33,5 +34,12 @@ class InstallmentPlanAdmin(admin.ModelAdmin):
] ]
@admin.register(RecurringTransaction)
class RecurringTransactionAdmin(admin.ModelAdmin):
inlines = [
TransactionInline,
]
admin.site.register(TransactionCategory) admin.site.register(TransactionCategory)
admin.site.register(TransactionTag) admin.site.register(TransactionTag)
+118 -142
View File
@@ -1,10 +1,13 @@
from crispy_bootstrap5.bootstrap5 import Switch from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Row, Column, Field from crispy_forms.layout import (
from dateutil.relativedelta import relativedelta Layout,
Row,
Column,
Field,
)
from django import forms from django import forms
from django.db import transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account from apps.accounts.models import Account
@@ -12,16 +15,17 @@ from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, DynamicModelMultipleChoiceField,
) )
from apps.common.fields.month_year import MonthYearFormField
from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect from apps.common.widgets.tom_select import TomSelect
from apps.transactions.models import ( from apps.transactions.models import (
Transaction, Transaction,
TransactionCategory, TransactionCategory,
TransactionTag, TransactionTag,
InstallmentPlan, InstallmentPlan,
RecurringTransaction,
) )
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.fields.month_year import MonthYearFormField
class TransactionForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
@@ -312,143 +316,6 @@ class TransferForm(forms.Form):
return from_transaction, to_transaction return from_transaction, to_transaction
# class InstallmentPlanForm(forms.Form):
# type = forms.ChoiceField(choices=Transaction.Type.choices)
# account = forms.ModelChoiceField(
# queryset=Account.objects.all(),
# label=_("Account"),
# widget=TomSelect(),
# )
# start_date = forms.DateField(
# label=_("Start Date"),
# widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
# )
# reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
# description = forms.CharField(max_length=500, label=_("Description"))
# number_of_installments = forms.IntegerField(
# min_value=1, label=_("Number of Installments")
# )
# recurrence = forms.ChoiceField(
# choices=(
# ("yearly", _("Yearly")),
# ("monthly", _("Monthly")),
# ("weekly", _("Weekly")),
# ("daily", _("Daily")),
# ),
# label=_("Recurrence"),
# initial="monthly",
# widget=TomSelect(clear_button=False),
# )
# installment_amount = forms.DecimalField(
# max_digits=42,
# decimal_places=30,
# required=True,
# label=_("Installment Amount"),
# )
# category = DynamicModelChoiceField(
# model=TransactionCategory,
# required=False,
# label=_("Category"),
# )
# tags = DynamicModelMultipleChoiceField(
# model=TransactionTag,
# to_field_name="name",
# create_field="name",
# required=False,
# label=_("Tags"),
# )
#
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
#
# self.helper = FormHelper()
# self.helper.form_tag = False
# self.helper.form_method = "post"
#
# self.helper.layout = Layout(
# Field(
# "type",
# template="transactions/widgets/income_expense_toggle_buttons.html",
# ),
# "account",
# "description",
# Row(
# Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
# Column("recurrence", css_class="form-group col-md-6 mb-0"),
# css_class="form-row",
# ),
# Row(
# Column("start_date", css_class="form-group col-md-6 mb-0"),
# Column("reference_date", css_class="form-group col-md-6 mb-0"),
# css_class="form-row",
# ),
# "installment_amount",
# Row(
# Column("category", css_class="form-group col-md-6 mb-0"),
# Column("tags", css_class="form-group col-md-6 mb-0"),
# css_class="form-row",
# ),
# FormActions(
# NoClassSubmit(
# "submit", _("Add"), css_class="btn btn-outline-primary w-100"
# ),
# ),
# )
#
# self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
#
# def save(self):
# number_of_installments = self.cleaned_data["number_of_installments"]
# transaction_type = self.cleaned_data["type"]
# start_date = self.cleaned_data["start_date"]
# reference_date = self.cleaned_data["reference_date"] or start_date
# recurrence = self.cleaned_data["recurrence"]
# account = self.cleaned_data["account"]
# description = self.cleaned_data["description"]
# installment_amount = self.cleaned_data["installment_amount"]
# category = self.cleaned_data["category"]
#
# print(reference_date, type(reference_date))
# print(start_date, type(start_date))
#
# with transaction.atomic():
# installment_plan = InstallmentPlan.objects.create(
# account=account,
# description=description,
# number_of_installments=number_of_installments,
# )
#
# with transaction.atomic():
# for i in range(number_of_installments):
# if recurrence == "yearly":
# delta = relativedelta(years=i)
# elif recurrence == "monthly":
# delta = relativedelta(months=i)
# elif recurrence == "weekly":
# delta = relativedelta(weeks=i)
# elif recurrence == "daily":
# delta = relativedelta(days=i)
#
# transaction_date = start_date + delta
# transaction_reference_date = (reference_date + delta).replace(day=1)
# new_transaction = Transaction.objects.create(
# account=account,
# type=transaction_type,
# date=transaction_date,
# is_paid=False,
# reference_date=transaction_reference_date,
# amount=installment_amount,
# description=description,
# notes=f"{i + 1}/{number_of_installments}",
# category=category,
# installment_plan=installment_plan,
# )
#
# new_transaction.tags.set(self.cleaned_data.get("tags", []))
#
# return installment_plan
class InstallmentPlanForm(forms.ModelForm): class InstallmentPlanForm(forms.ModelForm):
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
model=TransactionTag, model=TransactionTag,
@@ -615,3 +482,112 @@ class TransactionCategoryForm(forms.ModelForm):
), ),
), ),
) )
class RecurringTransactionForm(forms.ModelForm):
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
)
category = DynamicModelChoiceField(
model=TransactionCategory,
required=False,
label=_("Category"),
)
type = forms.ChoiceField(choices=Transaction.Type.choices)
reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
class Meta:
model = RecurringTransaction
fields = [
"account",
"type",
"amount",
"description",
"category",
"tags",
"start_date",
"reference_date",
"end_date",
"recurrence_type",
"recurrence_interval",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"account": TomSelect(clear_button=False),
"recurrence_type": TomSelect(clear_button=False),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Field(
"type",
template="transactions/widgets/income_expense_toggle_buttons.html",
),
"account",
"description",
"amount",
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
Column("tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
Row(
Column("start_date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
Row(
Column("recurrence_interval", css_class="form-group col-md-4 mb-0"),
Column("recurrence_type", css_class="form-group col-md-4 mb-0"),
Column("end_date", css_class="form-group col-md-4 mb-0"),
css_class="form-row",
),
)
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
def clean(self):
cleaned_data = super().clean()
start_date = cleaned_data.get("start_date")
end_date = cleaned_data.get("end_date")
if start_date and end_date and start_date > end_date:
raise forms.ValidationError("End date should be after the start date.")
return cleaned_data
def save(self, **kwargs):
is_new = not self.instance.id
instance = super().save(**kwargs)
if is_new:
instance.create_upcoming_transactions()
return instance
+170 -29
View File
@@ -1,17 +1,16 @@
import logging import logging
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Q from django.db.models import Q
from django.utils.functional import cached_property from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.common.functions.decimals import truncate_decimal
from apps.transactions.validators import validate_decimal_places, validate_non_negative
from apps.currencies.utils.convert import convert
from apps.common.fields.month_year import MonthYearModelField from apps.common.fields.month_year import MonthYearModelField
from apps.common.functions.decimals import truncate_decimal
from apps.currencies.utils.convert import convert
from apps.transactions.validators import validate_decimal_places, validate_non_negative
logger = logging.getLogger() logger = logging.getLogger()
@@ -41,30 +40,6 @@ class TransactionTag(models.Model):
return self.name return self.name
# class InstallmentPlan(models.Model):
# account = models.ForeignKey(
# "accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
# )
# description = models.CharField(max_length=500, verbose_name=_("Description"))
# number_of_installments = models.PositiveIntegerField(
# validators=[MinValueValidator(1)], verbose_name=_("Number of Installments")
# )
# # start_date = models.DateField(verbose_name=_("Start Date"))
# # end_date = models.DateField(verbose_name=_("End Date"))
#
# class Meta:
# verbose_name = _("Installment Plan")
# verbose_name_plural = _("Installment Plans")
#
# def __str__(self):
# return f"{self.description} - {self.number_of_installments} installments"
#
# def delete(self, *args, **kwargs):
# # Delete related transactions
# self.transactions.all().delete()
# super().delete(*args, **kwargs)
class Transaction(models.Model): class Transaction(models.Model):
class Type(models.TextChoices): class Type(models.TextChoices):
INCOME = "IN", _("Income") INCOME = "IN", _("Income")
@@ -110,6 +85,14 @@ class Transaction(models.Model):
verbose_name=_("Installment Plan"), verbose_name=_("Installment Plan"),
) )
installment_id = models.PositiveIntegerField(null=True, blank=True) installment_id = models.PositiveIntegerField(null=True, blank=True)
recurring_transaction = models.ForeignKey(
"RecurringTransaction",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="transactions",
verbose_name=_("Recurring Transaction"),
)
class Meta: class Meta:
verbose_name = _("Transaction") verbose_name = _("Transaction")
@@ -120,6 +103,7 @@ class Transaction(models.Model):
self.amount = truncate_decimal( self.amount = truncate_decimal(
value=self.amount, decimal_places=self.account.currency.decimal_places value=self.amount, decimal_places=self.account.currency.decimal_places
) )
self.reference_date = self.reference_date.replace(day=1)
self.full_clean() self.full_clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -323,3 +307,160 @@ class InstallmentPlan(models.Model):
# Delete related transactions # Delete related transactions
self.transactions.all().delete() self.transactions.all().delete()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
class RecurringTransaction(models.Model):
class RecurrenceType(models.TextChoices):
DAY = "day", _("day(s)")
WEEK = "week", _("week(s)")
MONTH = "month", _("month(s)")
YEAR = "year", _("year(s)")
account = models.ForeignKey(
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
)
type = models.CharField(
max_length=2,
choices=Transaction.Type,
default=Transaction.Type.EXPENSE,
verbose_name=_("Type"),
)
amount = models.DecimalField(
max_digits=42,
decimal_places=30,
verbose_name=_("Amount"),
validators=[validate_non_negative, validate_decimal_places],
)
description = models.CharField(max_length=500, verbose_name=_("Description"))
category = models.ForeignKey(
TransactionCategory,
on_delete=models.SET_NULL,
verbose_name=_("Category"),
blank=True,
null=True,
)
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
reference_date = models.DateField(
verbose_name=_("Reference Date"), null=True, blank=True
)
# Recurrence fields
start_date = models.DateField(verbose_name=_("Start Date"))
end_date = models.DateField(verbose_name=_("End Date"), null=True, blank=True)
recurrence_type = models.CharField(
max_length=7, choices=RecurrenceType, verbose_name=_("Recurrence Type")
)
recurrence_interval = models.PositiveIntegerField(
verbose_name=_("Recurrence Interval"),
)
last_generated_date = models.DateField(
verbose_name=_("Last Generated Date"), null=True, blank=True
)
last_generated_reference_date = models.DateField(
verbose_name=_("Last Generated Reference Date"), null=True, blank=True
)
class Meta:
verbose_name = _("Recurring Transaction")
verbose_name_plural = _("Recurring Transactions")
db_table = "recurring_transactions"
def __str__(self):
return self.description
def save(self, *args, **kwargs):
if not self.reference_date:
self.reference_date = self.start_date.replace(day=1)
instance = super().save(*args, **kwargs)
return instance
def create_upcoming_transactions(self):
current_date = self.start_date
reference_date = self.reference_date
end_date = min(
self.end_date or timezone.now().date() + relativedelta(years=1),
timezone.now().date() + relativedelta(years=1),
)
while current_date <= end_date:
self.create_transaction(current_date, reference_date)
current_date = self.get_next_date(current_date)
reference_date = self.get_next_date(reference_date)
self.last_generated_date = current_date - self.get_recurrence_delta()
self.last_generated_reference_date = (
reference_date - self.get_recurrence_delta()
)
self.save(
update_fields=["last_generated_date", "last_generated_reference_date"]
)
def create_transaction(self, date, reference_date):
created_transaction = Transaction.objects.create(
account=self.account,
type=self.type,
date=date,
reference_date=reference_date,
amount=self.amount,
description=self.description,
category=self.category,
is_paid=False,
recurring_transaction=self,
)
if self.tags.exists():
created_transaction.tags.set(self.tags.all())
def get_recurrence_delta(self):
if self.recurrence_type == self.RecurrenceType.DAY:
return relativedelta(days=self.recurrence_interval)
elif self.recurrence_type == self.RecurrenceType.WEEK:
return relativedelta(weeks=self.recurrence_interval)
elif self.recurrence_type == self.RecurrenceType.MONTH:
return relativedelta(months=self.recurrence_interval)
elif self.recurrence_type == self.RecurrenceType.YEAR:
return relativedelta(years=self.recurrence_interval)
def get_next_date(self, current_date):
return current_date + self.get_recurrence_delta()
@classmethod
def generate_upcoming_transactions(cls):
today = timezone.now().date()
recurring_transactions = cls.objects.filter(
models.Q(end_date__isnull=True) | models.Q(end_date__gte=today)
)
for recurring_transaction in recurring_transactions:
if recurring_transaction.last_generated_date:
start_date = recurring_transaction.get_next_date(
recurring_transaction.last_generated_date
)
reference_date = recurring_transaction.get_next_date(
recurring_transaction.last_generated_reference_date
)
else:
start_date = max(recurring_transaction.start_date, today)
reference_date = recurring_transaction.reference_date
current_date = start_date
end_date = min(
recurring_transaction.end_date or today + relativedelta(years=1),
today + relativedelta(years=1),
)
while current_date <= end_date:
recurring_transaction.create_transaction(current_date, reference_date)
current_date = recurring_transaction.get_next_date(current_date)
reference_date = recurring_transaction.get_next_date(reference_date)
recurring_transaction.last_generated_date = (
current_date - recurring_transaction.get_recurrence_delta()
)
recurring_transaction.last_generated_reference_date = (
reference_date - recurring_transaction.get_recurrence_delta()
)
recurring_transaction.save(
update_fields=["last_generated_date", "last_generated_reference_date"]
)
+9
View File
@@ -0,0 +1,9 @@
from procrastinate.contrib.django import app
from apps.transactions.models import RecurringTransaction
@app.periodic(cron="0 0 * * *")
@app.task
def generate_recurring_transactions():
RecurringTransaction.generate_upcoming_transactions()
+35
View File
@@ -103,4 +103,39 @@ urlpatterns = [
views.installment_plan_refresh, views.installment_plan_refresh,
name="installment_plan_refresh", name="installment_plan_refresh",
), ),
path(
"recurring-trasanctions/",
views.recurring_transactions_index,
name="recurring_trasanctions_index",
),
path(
"recurring-trasanctions/list/",
views.recurring_transactions_list,
name="recurring_transaction_list",
),
path(
"recurring-transactions/add/",
views.recurring_transaction_add,
name="recurring_transaction_add",
),
path(
"recurring-transactions/<int:recurring_transaction_id>/transactions/",
views.recurring_transaction_transactions,
name="recurring_transaction_transactions",
),
path(
"recurring-transactions/<int:recurring_transaction_id>/edit/",
views.recurring_transaction_edit,
name="recurring_transaction_edit",
),
path(
"recurring-transactions/<int:recurring_transaction_id>/delete/",
views.recurring_transaction_delete,
name="recurring_transaction_delete",
),
# path(
# "installment-plans/<int:installment_plan_id>/refresh/",
# views.installment_plan_refresh,
# name="installment_plan_refresh",
# ),
] ]
+1
View File
@@ -3,3 +3,4 @@ from .tags import *
from .categories import * from .categories import *
from .actions import * from .actions import *
from .installment_plans import * from .installment_plans import *
from .recurring_transactions import *
@@ -0,0 +1,145 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.common.decorators.htmx import only_htmx
from apps.transactions.forms import RecurringTransactionForm
from apps.transactions.models import RecurringTransaction
@login_required
@require_http_methods(["GET"])
def recurring_transactions_index(request):
return render(
request,
"recurring_transactions/pages/index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def recurring_transactions_list(request):
recurring_transactions = RecurringTransaction.objects.all().order_by("-end_date")
return render(
request,
"recurring_transactions/fragments/list.html",
{"recurring_transactions": recurring_transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def recurring_transaction_transactions(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(
RecurringTransaction, id=recurring_transaction_id
)
transactions = recurring_transaction.transactions.all().order_by(
"reference_date", "id"
)
return render(
request,
"recurring_transactions/fragments/list_transactions.html",
{"recurring_transaction": recurring_transaction, "transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def recurring_transaction_add(request):
if request.method == "POST":
form = RecurringTransactionForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Recurring Transaction added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
)
else:
form = RecurringTransactionForm()
return render(
request,
"recurring_transactions/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def recurring_transaction_edit(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(
RecurringTransaction, id=recurring_transaction_id
)
if request.method == "POST":
form = RecurringTransactionForm(request.POST, instance=recurring_transaction)
if form.is_valid():
form.save()
messages.success(request, _("Recurring Transaction updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
)
else:
form = RecurringTransactionForm(instance=recurring_transaction)
return render(
request,
"recurring_transactions/fragments/edit.html",
{"form": form, "recurring_transaction": recurring_transaction},
)
# @only_htmx
# @login_required
# @require_http_methods(["GET"])
# def recurring_transaction_refresh(request, installment_plan_id):
# installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
# installment_plan.update_transactions()
#
# messages.success(request, _("Installment Plan refreshed successfully"))
#
# return HttpResponse(
# status=204,
# headers={
# "HX-Trigger": "updated, hide_offcanvas, toasts",
# },
# )
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def recurring_transaction_delete(request, recurring_transaction_id):
recurring_transaction = get_object_or_404(
RecurringTransaction, id=recurring_transaction_id
)
recurring_transaction.delete()
messages.success(request, _("Recurring Transaction deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
)
+18 -3
View File
@@ -34,7 +34,24 @@
</a> </a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||installment_plans_index' %}" <a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{% translate 'Transactions' %}
</a>
<ul class="dropdown-menu">
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
<li><a class="dropdown-item {% active_link views='recurring_trasanctions_index' %}"
href="{% url 'recurring_trasanctions_index' %}">{% translate 'Recurring Transactions' %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index' %}"
href="#" role="button" href="#" role="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
@@ -46,8 +63,6 @@
href="{% url 'categories_index' %}">{% translate 'Categories' %}</a></li> href="{% url 'categories_index' %}">{% translate 'Categories' %}</a></li>
<li><a class="dropdown-item {% active_link views='tags_index' %}" <li><a class="dropdown-item {% active_link views='tags_index' %}"
href="{% url 'tags_index' %}">{% translate 'Tags' %}</a></li> href="{% url 'tags_index' %}">{% translate 'Tags' %}</a></li>
<li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
<li> <li>
<hr class="dropdown-divider"> <hr class="dropdown-divider">
</li> </li>
@@ -73,6 +73,12 @@
<i class="fa-solid fa-divide me-2"></i> <i class="fa-solid fa-divide me-2"></i>
{% translate "Installment" %} {% translate "Installment" %}
</button> </button>
<button class="btn btn-sm btn-outline-warning"
hx-get="{% url 'recurring_transaction_add' %}"
hx-trigger="click, balance from:window"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-repeat me-2"></i>
{% translate "Recurring" %}
<button class="btn btn-sm btn-outline-info" <button class="btn btn-sm btn-outline-info"
hx-get="{% url 'transactions_transfer' %}" hx-get="{% url 'transactions_transfer' %}"
hx-target="#generic-offcanvas" hx-target="#generic-offcanvas"
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add recurring transaction' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'recurring_transaction_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit recurring transaction' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'recurring_transaction_edit' recurring_transaction_id=recurring_transaction.id %}"
hx-target="#generic-offcanvas"
novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,74 @@
{% load i18n %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Recurring Transactions' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}"
hx-get="{% url 'recurring_transaction_add' %}"
hx-target="#generic-offcanvas"
_="">
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div>
{% endspaceless %}
</div>
<div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for recurring_transaction in recurring_transactions %}
<tr class="recurring_transaction">
<td class="col-auto text-center">
<a class="text-decoration-none tw-text-gray-400 p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'recurring_transaction_edit' recurring_transaction_id=recurring_transaction.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="text-decoration-none tw-text-gray-400 p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Transactions" %}"
hx-get="{% url 'recurring_transaction_transactions' recurring_transaction_id=recurring_transaction.id %}"
hx-target="#persistent-generic-offcanvas-left">
<i class="fa-solid fa-eye fa-fw"></i></a>
{# <a class="text-decoration-none text-info p-1 category-action"#}
{# role="button"#}
{# data-bs-toggle="tooltip"#}
{# data-bs-title="{% translate "Refresh" %}"#}
{# hx-get="{% url 'installment_plan_refresh' installment_plan_id=installment_plan.id %}"#}
{# hx-target="#generic-offcanvas"#}
{# hx-trigger='confirmed'#}
{# data-bypass-on-ctrl="true"#}
{# data-title="{% translate "Are you sure?" %}"#}
{# data-text="{% translate "This will update all transactions associated with this plan and recreate missing ones" %}"#}
{# data-confirm-text="{% translate "Yes, refresh it!" %}"#}
{# _="install prompt_swal">#}
{# <i class="fa-solid fa-arrows-rotate fa-fw"></i></a>#}
<a class="text-danger text-decoration-none p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'recurring_transaction_delete' recurring_transaction_id=recurring_transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "This will delete the recurrence and all transactions associated with it" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a></td>
<td class="col">{{ recurring_transaction.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,13 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Installments' %}{% endblock %}
{% block body %}
<div hx-get="{% url 'recurring_transaction_transactions' recurring_transaction_id=recurring_transaction.id %}" hx-trigger="updated from:window" hx-vals='{"disable_selection": true}' hx-target="closest .offcanvas" class="show-loading">
{% for transaction in transactions %}
{% include 'transactions/fragments/item.html' with transaction=transaction disable_selection=True %}
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Recurring Transactions' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'recurring_transaction_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}
+1 -1
View File
@@ -9,7 +9,7 @@ window.TomSelect = function createDynamicTomSelect(element) {
// Extract 'create' option from data attribute // Extract 'create' option from data attribute
create: element.dataset.create === 'true', create: element.dataset.create === 'true',
copyClassesToDropdown: true,
allowEmptyOption: element.dataset.allowEmptyOption === 'true', allowEmptyOption: element.dataset.allowEmptyOption === 'true',
render: { render: {
no_results: function () { no_results: function () {