changes, fixes and improvements

This commit is contained in:
Herculino Trotta
2024-10-16 00:16:48 -03:00
parent d0f4dcc957
commit 07cbfefb95
40 changed files with 1008 additions and 330 deletions

View File

@@ -8,7 +8,8 @@ urlpatterns = [
views.account_reconciliation, views.account_reconciliation,
name="account_reconciliation", name="account_reconciliation",
), ),
path("accounts/", views.accounts_list, name="accounts_list"), path("accounts/", views.accounts_index, name="accounts_index"),
path("accounts/list/", views.accounts_list, name="accounts_list"),
path("account/add/", views.account_add, name="account_add"), path("account/add/", views.account_add, name="account_add"),
path( path(
"account/<int:pk>/edit/", "account/<int:pk>/edit/",
@@ -20,7 +21,8 @@ urlpatterns = [
views.account_delete, views.account_delete,
name="account_delete", name="account_delete",
), ),
path("account-groups/", views.account_groups_list, name="account_groups_list"), path("account-groups/", views.account_groups_index, name="account_groups_index"),
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"), path("account-groups/add/", views.account_group_add, name="account_group_add"),
path( path(
"account-groups/<int:pk>/edit/", "account-groups/<int:pk>/edit/",

View File

@@ -12,12 +12,24 @@ from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@login_required
@require_http_methods(["GET"])
def account_groups_index(request):
return render(
request,
"account_groups/pages/index.html",
)
@only_htmx
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def account_groups_list(request): def account_groups_list(request):
account_groups = AccountGroup.objects.all().order_by("id") account_groups = AccountGroup.objects.all().order_by("id")
return render( return render(
request, "account_groups/pages/list.html", {"account_groups": account_groups} request,
"account_groups/fragments/list.html",
{"account_groups": account_groups},
) )
@@ -34,8 +46,7 @@ def account_group_add(request, **kwargs):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("account_groups_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -63,8 +74,7 @@ def account_group_edit(request, pk):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("account_groups_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -90,5 +100,7 @@ def account_group_delete(request, pk):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={"HX-Location": reverse("account_groups_list")}, headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
) )

View File

@@ -12,11 +12,25 @@ from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx from apps.common.decorators.htmx import only_htmx
@login_required
@require_http_methods(["GET"])
def accounts_index(request):
return render(
request,
"accounts/pages/index.html",
)
@only_htmx
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def accounts_list(request): def accounts_list(request):
accounts = Account.objects.all().order_by("id") accounts = Account.objects.all().order_by("id")
return render(request, "accounts/pages/list.html", {"accounts": accounts}) return render(
request,
"accounts/fragments/list.html",
{"accounts": accounts},
)
@only_htmx @only_htmx
@@ -32,8 +46,7 @@ def account_add(request, **kwargs):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("accounts_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -61,8 +74,7 @@ def account_edit(request, pk):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("accounts_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -88,5 +100,7 @@ def account_delete(request, pk):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={"HX-Location": reverse("accounts_list")}, headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
) )

View File

@@ -32,3 +32,11 @@ class TransactionTagViewSet(viewsets.ModelViewSet):
class InstallmentPlanViewSet(viewsets.ModelViewSet): class InstallmentPlanViewSet(viewsets.ModelViewSet):
queryset = InstallmentPlan.objects.all() queryset = InstallmentPlan.objects.all()
serializer_class = InstallmentPlanSerializer serializer_class = InstallmentPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
instance.create_transactions()
def perform_update(self, serializer):
instance = serializer.save()
instance.create_transactions()

View File

@@ -3,7 +3,8 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path("currencies/", views.currency_list, name="currencies_list"), path("currencies/", views.currencies_index, name="currencies_index"),
path("currencies/list/", views.currencies_list, name="currencies_list"),
path("currencies/add/", views.currency_add, name="currency_add"), path("currencies/add/", views.currency_add, name="currency_add"),
path( path(
"currencies/<int:pk>/edit/", "currencies/<int:pk>/edit/",

View File

@@ -14,9 +14,23 @@ from apps.currencies.models import Currency
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def currency_list(request): def currencies_index(request):
return render(
request,
"currencies/pages/index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def currencies_list(request):
currencies = Currency.objects.all().order_by("id") currencies = Currency.objects.all().order_by("id")
return render(request, "currencies/pages/list.html", {"currencies": currencies}) return render(
request,
"currencies/fragments/list.html",
{"currencies": currencies},
)
@only_htmx @only_htmx
@@ -32,8 +46,7 @@ def currency_add(request, **kwargs):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("currencies_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -61,8 +74,7 @@ def currency_edit(request, pk):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("currencies_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -88,5 +100,7 @@ def currency_delete(request, pk):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={"HX-Location": reverse("currencies_list")}, headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
) )

View File

@@ -103,6 +103,7 @@ def transactions_list(request, month: int, year: int):
"tags", "tags",
"account__exchange_currency", "account__exchange_currency",
"account__currency", "account__currency",
"installment_plan",
) )
) )
return render( return render(

View File

@@ -312,44 +312,144 @@ class TransferForm(forms.Form):
return from_transaction, to_transaction return from_transaction, to_transaction
class InstallmentPlanForm(forms.Form): # class InstallmentPlanForm(forms.Form):
type = forms.ChoiceField(choices=Transaction.Type.choices) # type = forms.ChoiceField(choices=Transaction.Type.choices)
account = forms.ModelChoiceField( # account = forms.ModelChoiceField(
queryset=Account.objects.all(), # queryset=Account.objects.all(),
label=_("Account"), # label=_("Account"),
widget=TomSelect(), # widget=TomSelect(),
) # )
start_date = forms.DateField( # start_date = forms.DateField(
label=_("Start Date"), # label=_("Start Date"),
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), # widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
) # )
reference_date = MonthYearFormField(label=_("Reference Date"), required=False) # reference_date = MonthYearFormField(label=_("Reference Date"), required=False)
description = forms.CharField(max_length=500, label=_("Description")) # description = forms.CharField(max_length=500, label=_("Description"))
number_of_installments = forms.IntegerField( # number_of_installments = forms.IntegerField(
min_value=1, label=_("Number of Installments") # min_value=1, label=_("Number of Installments")
) # )
recurrence = forms.ChoiceField( # recurrence = forms.ChoiceField(
choices=( # choices=(
("yearly", _("Yearly")), # ("yearly", _("Yearly")),
("monthly", _("Monthly")), # ("monthly", _("Monthly")),
("weekly", _("Weekly")), # ("weekly", _("Weekly")),
("daily", _("Daily")), # ("daily", _("Daily")),
), # ),
label=_("Recurrence"), # label=_("Recurrence"),
initial="monthly", # initial="monthly",
widget=TomSelect(clear_button=False), # widget=TomSelect(clear_button=False),
) # )
installment_amount = forms.DecimalField( # installment_amount = forms.DecimalField(
max_digits=42, # max_digits=42,
decimal_places=30, # decimal_places=30,
required=True, # required=True,
label=_("Installment Amount"), # label=_("Installment Amount"),
) # )
category = DynamicModelChoiceField( # category = DynamicModelChoiceField(
model=TransactionCategory, # model=TransactionCategory,
required=False, # required=False,
label=_("Category"), # 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):
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
model=TransactionTag, model=TransactionTag,
to_field_name="name", to_field_name="name",
@@ -357,6 +457,34 @@ class InstallmentPlanForm(forms.Form):
required=False, required=False,
label=_("Tags"), 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 = InstallmentPlan
fields = [
"type",
"account",
"start_date",
"reference_date",
"description",
"number_of_installments",
"recurrence",
"installment_amount",
"category",
"tags",
"installment_start",
]
widgets = {
"start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"account": TomSelect(),
"recurrence": TomSelect(clear_button=False),
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -374,9 +502,10 @@ class InstallmentPlanForm(forms.Form):
"description", "description",
Row( Row(
Column("number_of_installments", css_class="form-group col-md-6 mb-0"), Column("number_of_installments", css_class="form-group col-md-6 mb-0"),
Column("recurrence", css_class="form-group col-md-6 mb-0"), Column("installment_start", css_class="form-group col-md-6 mb-0"),
css_class="form-row", css_class="form-row",
), ),
"recurrence",
Row( Row(
Column("start_date", css_class="form-group col-md-6 mb-0"), Column("start_date", css_class="form-group col-md-6 mb-0"),
Column("reference_date", css_class="form-group col-md-6 mb-0"), Column("reference_date", css_class="form-group col-md-6 mb-0"),
@@ -388,65 +517,37 @@ class InstallmentPlanForm(forms.Form):
Column("tags", css_class="form-group col-md-6 mb-0"), Column("tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row", css_class="form-row",
), ),
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
) )
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput() self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
def save(self): if self.instance and self.instance.pk:
number_of_installments = self.cleaned_data["number_of_installments"] self.helper.layout.append(
transaction_type = self.cleaned_data["type"] FormActions(
start_date = self.cleaned_data["start_date"] NoClassSubmit(
reference_date = self.cleaned_data["reference_date"] or start_date "submit", _("Update"), css_class="btn btn-outline-primary w-100"
recurrence = self.cleaned_data["recurrence"] ),
account = self.cleaned_data["account"] ),
description = self.cleaned_data["description"] )
installment_amount = self.cleaned_data["installment_amount"] else:
category = self.cleaned_data["category"] self.helper.layout.append(
FormActions(
print(reference_date, type(reference_date)) NoClassSubmit(
print(start_date, type(start_date)) "submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
with transaction.atomic(): ),
installment_plan = InstallmentPlan.objects.create(
account=account,
description=description,
number_of_installments=number_of_installments,
) )
with transaction.atomic(): def save(self, **kwargs):
for i in range(number_of_installments): is_new = not self.instance.id
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 instance = super().save(**kwargs)
transaction_reference_date = (reference_date + delta).replace(day=1) if is_new:
new_transaction = Transaction.objects.create( instance.create_transactions()
account=account, else:
type=transaction_type, instance.update_transactions()
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 instance
return installment_plan
class TransactionTagForm(forms.ModelForm): class TransactionTagForm(forms.ModelForm):

View File

@@ -1,7 +1,10 @@
import logging
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models, transaction
from django.db.models import Q
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -10,6 +13,8 @@ from apps.transactions.validators import validate_decimal_places, validate_non_n
from apps.currencies.utils.convert import convert from apps.currencies.utils.convert import convert
from apps.common.fields.month_year import MonthYearModelField from apps.common.fields.month_year import MonthYearModelField
logger = logging.getLogger()
class TransactionCategory(models.Model): class TransactionCategory(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
@@ -36,28 +41,28 @@ class TransactionTag(models.Model):
return self.name return self.name
class InstallmentPlan(models.Model): # class InstallmentPlan(models.Model):
account = models.ForeignKey( # account = models.ForeignKey(
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account") # "accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
) # )
description = models.CharField(max_length=500, verbose_name=_("Description")) # description = models.CharField(max_length=500, verbose_name=_("Description"))
number_of_installments = models.PositiveIntegerField( # number_of_installments = models.PositiveIntegerField(
validators=[MinValueValidator(1)], verbose_name=_("Number of Installments") # validators=[MinValueValidator(1)], verbose_name=_("Number of Installments")
) # )
# start_date = models.DateField(verbose_name=_("Start Date")) # # start_date = models.DateField(verbose_name=_("Start Date"))
# end_date = models.DateField(verbose_name=_("End Date")) # # end_date = models.DateField(verbose_name=_("End Date"))
#
class Meta: # class Meta:
verbose_name = _("Installment Plan") # verbose_name = _("Installment Plan")
verbose_name_plural = _("Installment Plans") # verbose_name_plural = _("Installment Plans")
#
def __str__(self): # def __str__(self):
return f"{self.description} - {self.number_of_installments} installments" # return f"{self.description} - {self.number_of_installments} installments"
#
def delete(self, *args, **kwargs): # def delete(self, *args, **kwargs):
# Delete related transactions # # Delete related transactions
self.transactions.all().delete() # self.transactions.all().delete()
super().delete(*args, **kwargs) # super().delete(*args, **kwargs)
class Transaction(models.Model): class Transaction(models.Model):
@@ -97,13 +102,14 @@ class Transaction(models.Model):
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True) tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
installment_plan = models.ForeignKey( installment_plan = models.ForeignKey(
InstallmentPlan, "InstallmentPlan",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True, null=True,
blank=True, blank=True,
related_name="transactions", related_name="transactions",
verbose_name=_("Installment Plan"), verbose_name=_("Installment Plan"),
) )
installment_id = models.PositiveIntegerField(null=True, blank=True)
class Meta: class Meta:
verbose_name = _("Transaction") verbose_name = _("Transaction")
@@ -134,3 +140,186 @@ class Transaction(models.Model):
} }
return None return None
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):
YEARLY = "yearly", _("Yearly")
MONTHLY = "monthly", _("Monthly")
WEEKLY = "weekly", _("Weekly")
DAILY = "daily", _("Daily")
account = models.ForeignKey(
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
)
type = models.CharField(
max_length=10,
choices=Transaction.Type,
verbose_name=_("Type"),
)
description = models.CharField(max_length=500, verbose_name=_("Description"))
number_of_installments = models.PositiveIntegerField(
validators=[MinValueValidator(1)],
verbose_name=_("Number of Installments"),
default=1,
)
installment_start = models.PositiveIntegerField(
validators=[MinValueValidator(1)],
verbose_name=_("Installment Start"),
help_text=_("The installment number to start counting from"),
blank=True,
default=1,
)
installment_total_number = models.PositiveIntegerField()
start_date = models.DateField(verbose_name=_("Start Date"))
reference_date = models.DateField(
verbose_name=_("Reference Date"), null=True, blank=True
)
end_date = models.DateField(verbose_name=_("End Date"), null=True, blank=True)
recurrence = models.CharField(
max_length=10,
choices=Recurrence,
default=Recurrence.MONTHLY,
verbose_name=_("Recurrence"),
)
installment_amount = models.DecimalField(
max_digits=42, decimal_places=30, verbose_name=_("Installment Amount")
)
category = models.ForeignKey(
"TransactionCategory",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Category"),
)
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
class Meta:
verbose_name = _("Installment Plan")
verbose_name_plural = _("Installment Plans")
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)
if not self.installment_start:
self.installment_start = 1
self.end_date = self._calculate_end_date()
self.installment_total_number = self._calculate_installment_total_number()
instance = super().save(*args, **kwargs)
return instance
def _calculate_end_date(self):
if self.recurrence == self.Recurrence.YEARLY:
delta = relativedelta(years=self.number_of_installments - 1)
elif self.recurrence == self.Recurrence.MONTHLY:
delta = relativedelta(months=self.number_of_installments - 1)
elif self.recurrence == self.Recurrence.WEEKLY:
delta = relativedelta(weeks=self.number_of_installments - 1)
else:
delta = relativedelta(days=self.number_of_installments - 1)
return self.start_date + delta
def _calculate_installment_total_number(self):
return self.number_of_installments + (self.installment_start - 1)
@transaction.atomic
def create_transactions(self):
self.transactions.all().delete()
for i in range(
self.installment_start,
self.installment_total_number + 1,
):
if self.recurrence == self.Recurrence.YEARLY:
delta = relativedelta(years=i - self.installment_start)
elif self.recurrence == self.Recurrence.MONTHLY:
delta = relativedelta(months=i - self.installment_start)
elif self.recurrence == self.Recurrence.WEEKLY:
delta = relativedelta(weeks=i - self.installment_start)
else:
delta = relativedelta(days=i - self.installment_start)
transaction_date = self.start_date + delta
transaction_reference_date = (self.reference_date + delta).replace(day=1)
new_transaction = Transaction.objects.create(
account=self.account,
type=self.type,
date=transaction_date,
is_paid=False,
reference_date=transaction_reference_date,
amount=self.installment_amount,
description=self.description,
category=self.category,
installment_plan=self,
installment_id=i,
)
new_transaction.tags.set(self.tags.all())
@transaction.atomic
def update_transactions(self):
existing_transactions = self.transactions.all().order_by("installment_id")
for i in range(self.installment_start, self.installment_total_number + 1):
if self.recurrence == self.Recurrence.YEARLY:
delta = relativedelta(years=i - self.installment_start)
elif self.recurrence == self.Recurrence.MONTHLY:
delta = relativedelta(months=i - self.installment_start)
elif self.recurrence == self.Recurrence.WEEKLY:
delta = relativedelta(weeks=i - self.installment_start)
else:
delta = relativedelta(days=i - self.installment_start)
transaction_date = self.start_date + delta
transaction_reference_date = (self.reference_date + delta).replace(day=1)
# Get the existing transaction or None if it doesn't exist
existing_transaction = existing_transactions.filter(
installment_id=i
).first()
if existing_transaction:
# Update existing transaction
existing_transaction.account = self.account
existing_transaction.type = self.type
existing_transaction.date = transaction_date
existing_transaction.reference_date = transaction_reference_date
existing_transaction.amount = self.installment_amount
existing_transaction.description = self.description
existing_transaction.category = self.category
existing_transaction.save()
# Update tags
existing_transaction.tags.set(self.tags.all())
else:
# If the transaction doesn't exist, create a new one
new_transaction = Transaction.objects.create(
account=self.account,
type=self.type,
date=transaction_date,
is_paid=False,
reference_date=transaction_reference_date,
amount=self.installment_amount,
description=self.description,
category=self.category,
installment_plan=self,
installment_id=i,
)
new_transaction.tags.set(self.tags.all())
# Remove any extra transactions that are no longer part of the plan
self.transactions.filter(
Q(installment_id__gt=self.installment_total_number)
| Q(installment_id__lt=self.installment_start)
).delete()
def delete(self, *args, **kwargs):
# Delete related transactions
self.transactions.all().delete()
super().delete(*args, **kwargs)

View File

@@ -42,12 +42,8 @@ urlpatterns = [
views.transactions_transfer, views.transactions_transfer,
name="transactions_transfer", name="transactions_transfer",
), ),
path( path("tags/", views.tags_index, name="tags_index"),
"transactions/installments/add/", path("tags/list/", views.tags_list, name="tags_list"),
views.AddInstallmentPlanView.as_view(),
name="installments_add",
),
path("tags/", views.tag_list, name="tags_list"),
path("tags/add/", views.tag_add, name="tag_add"), path("tags/add/", views.tag_add, name="tag_add"),
path( path(
"tags/<int:tag_id>/edit/", "tags/<int:tag_id>/edit/",
@@ -59,7 +55,8 @@ urlpatterns = [
views.tag_delete, views.tag_delete,
name="tag_delete", name="tag_delete",
), ),
path("categories/", views.categories_list, name="categories_list"), path("categories/", views.categories_index, name="categories_index"),
path("categories/list/", views.categories_list, name="categories_list"),
path("categories/add/", views.category_add, name="category_add"), path("categories/add/", views.category_add, name="category_add"),
path( path(
"categories/<int:category_id>/edit/", "categories/<int:category_id>/edit/",
@@ -71,4 +68,39 @@ urlpatterns = [
views.category_delete, views.category_delete,
name="category_delete", name="category_delete",
), ),
path(
"installment-plans/",
views.installment_plans_index,
name="installment_plans_index",
),
path(
"installment-plans/list/",
views.installment_plans_list,
name="installment_plans_list",
),
path(
"installment-plans/add/",
views.installment_plan_add,
name="installment_plan_add",
),
path(
"installment-plans/<int:installment_plan_id>/transactions/",
views.installment_plan_transactions,
name="installment_plan_transactions",
),
path(
"installment-plans/<int:installment_plan_id>/edit/",
views.installment_plan_edit,
name="installment_plan_edit",
),
path(
"installment-plans/<int:installment_plan_id>/delete/",
views.installment_plan_delete,
name="installment_plan_delete",
),
path(
"installment-plans/<int:installment_plan_id>/refresh/",
views.installment_plan_refresh,
name="installment_plan_refresh",
),
] ]

View File

@@ -2,3 +2,4 @@ from .transactions import *
from .tags import * from .tags import *
from .categories import * from .categories import *
from .actions import * from .actions import *
from .installment_plans import *

View File

@@ -33,9 +33,7 @@ def bulk_unpay_transactions(request):
@login_required @login_required
def bulk_delete_transactions(request): def bulk_delete_transactions(request):
selected_transactions = request.GET.getlist("transactions", []) selected_transactions = request.GET.getlist("transactions", [])
Transaction.objects.filter( Transaction.objects.filter(id__in=selected_transactions).delete()
id__in=selected_transactions, installment_plan__isnull=True
).delete()
return HttpResponse( return HttpResponse(
status=204, status=204,

View File

@@ -12,11 +12,25 @@ from apps.transactions.forms import TransactionCategoryForm
from apps.transactions.models import TransactionCategory from apps.transactions.models import TransactionCategory
@login_required
@require_http_methods(["GET"])
def categories_index(request):
return render(
request,
"categories/pages/index.html",
)
@only_htmx
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def categories_list(request): def categories_list(request):
categories = TransactionCategory.objects.all().order_by("id") categories = TransactionCategory.objects.all().order_by("id")
return render(request, "categories/pages/list.html", {"categories": categories}) return render(
request,
"categories/fragments/list.html",
{"categories": categories},
)
@only_htmx @only_htmx
@@ -32,8 +46,7 @@ def category_add(request, **kwargs):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("categories_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -61,8 +74,7 @@ def category_edit(request, category_id):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("categories_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -88,5 +100,7 @@ def category_delete(request, category_id):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={"HX-Location": reverse("categories_list")}, headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
) )

View File

@@ -0,0 +1,139 @@
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 import timezone
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 InstallmentPlanForm
from apps.transactions.models import InstallmentPlan
@login_required
@require_http_methods(["GET"])
def installment_plans_index(request):
return render(
request,
"installment_plans/pages/index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def installment_plans_list(request):
installment_plans = InstallmentPlan.objects.all().order_by("-end_date")
return render(
request,
"installment_plans/fragments/list.html",
{"installment_plans": installment_plans},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def installment_plan_transactions(request, installment_plan_id):
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
transactions = installment_plan.transactions.all().order_by("reference_date", "id")
print(transactions)
return render(
request,
"installment_plans/fragments/list_transactions.html",
{"installment_plan": installment_plan, "transactions": transactions},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def installment_plan_add(request):
if request.method == "POST":
form = InstallmentPlanForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Installment Plan added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
)
else:
form = InstallmentPlanForm()
return render(
request,
"installment_plans/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def installment_plan_edit(request, installment_plan_id):
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
if request.method == "POST":
form = InstallmentPlanForm(request.POST, instance=installment_plan)
if form.is_valid():
form.save()
messages.success(request, _("Installment Plan updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
)
else:
form = InstallmentPlanForm(instance=installment_plan)
return render(
request,
"installment_plans/fragments/edit.html",
{"form": form, "installment_plan": installment_plan},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def installment_plan_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 installment_plan_delete(request, installment_plan_id):
installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id)
installment_plan.delete()
messages.success(request, _("Installment Plan deleted successfully"))
return HttpResponse(
status=204,
headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
)

View File

@@ -14,9 +14,23 @@ from apps.transactions.models import TransactionTag
@login_required @login_required
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def tag_list(request): def tags_index(request):
return render(
request,
"tags/pages/index.html",
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def tags_list(request):
tags = TransactionTag.objects.all().order_by("id") tags = TransactionTag.objects.all().order_by("id")
return render(request, "tags/pages/list.html", {"tags": tags}) return render(
request,
"tags/fragments/list.html",
{"tags": tags},
)
@only_htmx @only_htmx
@@ -32,8 +46,7 @@ def tag_add(request, **kwargs):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("tags_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -61,8 +74,7 @@ def tag_edit(request, tag_id):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={ headers={
"HX-Location": reverse("tags_list"), "HX-Trigger": "updated, hide_offcanvas, toasts",
"HX-Trigger": "hide_offcanvas, toasts",
}, },
) )
else: else:
@@ -88,5 +100,7 @@ def tag_delete(request, tag_id):
return HttpResponse( return HttpResponse(
status=204, status=204,
headers={"HX-Location": reverse("tags_list")}, headers={
"HX-Trigger": "updated, hide_offcanvas, toasts",
},
) )

View File

@@ -88,17 +88,9 @@ def transaction_edit(request, transaction_id, **kwargs):
def transaction_delete(request, transaction_id, **kwargs): def transaction_delete(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id) transaction = get_object_or_404(Transaction, id=transaction_id)
if transaction.installment_plan: transaction.delete()
messages.error(
request,
_(
"This transaction is part of a Installment Plan, you can't delete it directly."
),
)
else:
transaction.delete()
messages.success(request, _("Transaction deleted successfully")) messages.success(request, _("Transaction deleted successfully"))
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -152,30 +144,9 @@ def transaction_pay(request, transaction_id):
response = render( response = render(
request, request,
"transactions/fragments/item.html", "transactions/fragments/item.html",
context={"transaction": transaction}, context={"transaction": transaction, **request.GET},
) )
response.headers["HX-Trigger"] = ( response.headers["HX-Trigger"] = (
f'{"paid" if new_is_paid else "unpaid"}, monthly_summary_update' f'{"paid" if new_is_paid else "unpaid"}, monthly_summary_update'
) )
return response return response
class AddInstallmentPlanView(View):
template_name = "transactions/fragments/add_installment_plan.html"
def get(self, request):
form = InstallmentPlanForm()
return render(request, self.template_name, {"form": form})
def post(self, request):
form = InstallmentPlanForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Installment plan created successfully"))
return HttpResponse(
status=204,
headers={"HX-Trigger": "updated, hide_offcanvas, toast"},
)
return render(request, self.template_name, {"form": form})

View File

@@ -10,7 +10,7 @@ class UserManager(BaseUserManager):
email = self.normalize_email(email) email = self.normalize_email(email)
user = self.model(email=email, **extra_fields) user = self.model(email=email, **extra_fields)
user.set_password(password) user.set_password(password)
user.save(using=self._db) user.save()
return user return user
def create_user(self, email, password=None, **extra_fields): def create_user(self, email, password=None, **extra_fields):

View File

@@ -1,9 +1,4 @@
{% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% translate 'Accounts' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5"> <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"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
@@ -20,8 +15,8 @@
{% endspaceless %} {% endspaceless %}
</div> </div>
<div class="border p-3 rounded-3"> <div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover table-responsive"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
@@ -32,14 +27,14 @@
{% for account_group in account_groups %} {% for account_group in account_groups %}
<tr class="account_group"> <tr class="account_group">
<td class="col-auto"> <td class="col-auto">
<a class="text-decoration-none tw-text-gray-400 p-1 tag-action" <a class="text-decoration-none tw-text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'account_group_edit' pk=account_group.id %}" hx-get="{% url 'account_group_edit' pk=account_group.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="text-danger text-decoration-none p-1 tag-action" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
@@ -57,4 +52,3 @@
</table> </table>
</div> </div>
</div> </div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Account Groups' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'account_groups_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}

View File

@@ -1,9 +1,4 @@
{% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% translate 'Accounts' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5"> <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"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
@@ -20,8 +15,8 @@
{% endspaceless %} {% endspaceless %}
</div> </div>
<div class="border p-3 rounded-3"> <div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover table-responsive"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
@@ -33,16 +28,16 @@
</thead> </thead>
<tbody> <tbody>
{% for account in accounts %} {% for account in accounts %}
<tr class="currency"> <tr class="account">
<td class="col-auto"> <td class="col-auto">
<a class="text-decoration-none tw-text-gray-400 p-1 tag-action" <a class="text-decoration-none tw-text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'account_edit' pk=account.id %}" hx-get="{% url 'account_edit' pk=account.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="text-danger text-decoration-none p-1 tag-action" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
@@ -64,4 +59,3 @@
</table> </table>
</div> </div>
</div> </div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Accounts' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'accounts_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}

View File

@@ -1,9 +1,4 @@
{% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% translate 'Categories' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5"> <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"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
@@ -20,8 +15,8 @@
{% endspaceless %} {% endspaceless %}
</div> </div>
<div class="border p-3 rounded-3"> <div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover table-responsive"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
@@ -61,4 +56,3 @@
</table> </table>
</div> </div>
</div> </div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Categories' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'categories_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}

View File

@@ -1,9 +1,4 @@
{% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% translate 'Currencies' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5"> <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"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
@@ -20,8 +15,8 @@
{% endspaceless %} {% endspaceless %}
</div> </div>
<div class="border p-3 rounded-3"> <div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover table-responsive"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
@@ -33,14 +28,14 @@
{% for currency in currencies %} {% for currency in currencies %}
<tr class="currency"> <tr class="currency">
<td class="col-auto"> <td class="col-auto">
<a class="text-decoration-none tw-text-gray-400 p-1 tag-action" <a class="text-decoration-none tw-text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'currency_edit' pk=currency.id %}" hx-get="{% url 'currency_edit' pk=currency.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="text-danger text-decoration-none p-1 tag-action" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
@@ -59,4 +54,3 @@
</table> </table>
</div> </div>
</div> </div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Currencies' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'currencies_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}

View File

@@ -2,68 +2,81 @@
{% load i18n %} {% load i18n %}
{% load active_link %} {% load active_link %}
<nav class="navbar navbar-expand-lg border-bottom bg-body-tertiary" hx-boost="true"> <nav class="navbar navbar-expand-lg border-bottom bg-body-tertiary" hx-boost="true">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand fw-bold text-primary font-base" href="{% url 'monthly_index' %}"> <a class="navbar-brand fw-bold text-primary font-base" href="{% url 'monthly_index' %}">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="40" title="WYGIWYH"/> <img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="40" title="WYGIWYH"/>
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
aria-controls="navbarContent" aria-expanded="false" aria-label={% translate "Toggle navigation" %}> aria-controls="navbarContent" aria-expanded="false" aria-label={% translate "Toggle navigation" %}>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarContent"> <div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 nav-underline" hx-push-url="true"> <ul class="navbar-nav me-auto mb-2 mb-lg-0 nav-underline" hx-push-url="true">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='monthly_overview||yearly_overview' %}" <a class="nav-link dropdown-toggle {% active_link views='monthly_overview||yearly_overview' %}"
href="#" href="#"
role="button" role="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
{% translate 'Overview' %} {% translate 'Overview' %}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item {% active_link views='monthly_overview' %}" href="{% url 'monthly_index' %}">{%translate 'Monthly' %}</a></li> <li><a class="dropdown-item {% active_link views='monthly_overview' %}"
<li><a class="dropdown-item {% active_link views='yearly_overview' %}" href="{% url 'yearly_index' %}">{%translate 'Yearly' %}</a></li> href="{% url 'monthly_index' %}">{% translate 'Monthly' %}</a></li>
</ul> <li><a class="dropdown-item {% active_link views='yearly_overview' %}"
</li> href="{% url 'yearly_index' %}">{% translate 'Yearly' %}</a></li>
<li class="nav-item"> </ul>
<a class="nav-link {% active_link views='net_worth' %}" </li>
href="{% url 'net_worth' %}"> <li class="nav-item">
{% translate 'Net Worth' %} <a class="nav-link {% active_link views='net_worth' %}"
</a> href="{% url 'net_worth' %}">
</li> {% translate 'Net Worth' %}
<li class="nav-item dropdown"> </a>
<a class="nav-link dropdown-toggle {% active_link views='tags_list||categories_list||currencies_list' %}" </li>
href="#" role="button" <li class="nav-item dropdown">
data-bs-toggle="dropdown" <a class="nav-link dropdown-toggle {% active_link views='tags_index||categories_index||accounts_index||account_groups_index||currencies_index||installment_plans_index' %}"
aria-expanded="false"> href="#" role="button"
{% translate 'Management' %} data-bs-toggle="dropdown"
</a> aria-expanded="false">
<ul class="dropdown-menu"> {% translate 'Management' %}
<li><h6 class="dropdown-header">{% trans 'Transactions' %}</h6></li> </a>
<li><a class="dropdown-item {% active_link views='categories_list' %}" href="{% url 'categories_list' %}">{% translate 'Categories' %}</a></li> <ul class="dropdown-menu">
<li><a class="dropdown-item {% active_link views='tags_list' %}" href="{% url 'tags_list' %}">{% translate 'Tags' %}</a></li> <li><h6 class="dropdown-header">{% trans 'Transactions' %}</h6></li>
<li><hr class="dropdown-divider"></li> <li><a class="dropdown-item {% active_link views='categories_index' %}"
<li><h6 class="dropdown-header">{% trans 'Accounts' %}</h6></li> href="{% url 'categories_index' %}">{% translate 'Categories' %}</a></li>
<li><a class="dropdown-item {% active_link views='accounts_list' %}" href="{% url 'accounts_list' %}">{% translate 'Accounts' %}</a></li> <li><a class="dropdown-item {% active_link views='tags_index' %}"
<li><a class="dropdown-item {% active_link views='account_groups_list' %}" href="{% url 'account_groups_list' %}">{% translate 'Account Groups' %}</a></li> href="{% url 'tags_index' %}">{% translate 'Tags' %}</a></li>
<li><a class="dropdown-item {% active_link views='currencies_list' %}" href="{% url 'currencies_list' %}">{% translate 'Currencies' %}</a></li> <li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
<li><hr class="dropdown-divider"></li> href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
<li> <li>
<a class="dropdown-item" <hr class="dropdown-divider">
href="{% url 'admin:index' %}" </li>
hx-boost="false" <li><h6 class="dropdown-header">{% trans 'Accounts' %}</h6></li>
data-bs-placement="right" <li><a class="dropdown-item {% active_link views='accounts_index' %}"
data-bs-toggle="tooltip" href="{% url 'accounts_index' %}">{% translate 'Accounts' %}</a></li>
data-bs-title="{% translate "Only use this if you know what you're doing" %}"> <li><a class="dropdown-item {% active_link views='account_groups_index' %}"
{% translate 'Django Admin' %} href="{% url 'account_groups_index' %}">{% translate 'Account Groups' %}</a></li>
</a> <li><a class="dropdown-item {% active_link views='currencies_index' %}"
</li> href="{% url 'currencies_index' %}">{% translate 'Currencies' %}</a></li>
</ul> <li>
</li> <hr class="dropdown-divider">
</ul> </li>
<ul class="navbar-nav mt-3 mb-2 mb-lg-0 mt-lg-0"> <li>
<li class="text-center w-100">{% include 'includes/navbar/user_menu.html' %}</li> <a class="dropdown-item"
</ul> href="{% url 'admin:index' %}"
</div> hx-boost="false"
data-bs-placement="right"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Only use this if you know what you're doing" %}">
{% translate 'Django Admin' %}
</a>
</li>
</ul>
</li>
</ul>
<ul class="navbar-nav mt-3 mb-2 mb-lg-0 mt-lg-0">
<li class="text-center w-100">{% include 'includes/navbar/user_menu.html' %}</li>
</ul>
</div> </div>
</div>
</nav> </nav>

View File

@@ -14,3 +14,17 @@
on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide() on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide()
on hidden.bs.offcanvas set my innerHTML to '' end"> on hidden.bs.offcanvas set my innerHTML to '' end">
</div> </div>
<div id="persistent-generic-offcanvas" class="offcanvas offcanvas-end offcanvas-size-xl"
data-bs-backdrop="static"
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide()
on hidden.bs.offcanvas set my innerHTML to '' end">
</div>
<div id="persistent-generic-offcanvas-left" class="offcanvas offcanvas-start offcanvas-size-xl"
data-bs-backdrop="static"
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide()
on hidden.bs.offcanvas set my innerHTML to '' end">
</div>

View File

@@ -1,13 +1,13 @@
<script type="text/hyperscript"> <script type="text/hyperscript">
behavior hide_amounts behavior hide_amounts
on load or htmx:afterSwap if I include #settings-hide-amounts on load or htmx:afterSwap if body include #settings-hide-amounts
set elements to <.amount/> in me set elements to <.amount/> in me
for el in elements for el in elements
set el.textContent to '•••••••••••' set el.textContent to '•••••••••••'
end end
end end
on load or htmx:afterSwap if I do not include #settings-hide-amounts on load or htmx:afterSwap if body do not include #settings-hide-amounts
set elements to <.amount/> in me set elements to <.amount/> in me
for el in elements for el in elements
set el.textContent to el.dataset.originalValue set el.textContent to el.dataset.originalValue

View File

@@ -1,3 +1,3 @@
<div id="toasts" hx-get="{% url 'toasts' %}" <div id="toasts" hx-get="{% url 'toasts' %}"
hx-trigger="load, toast from:window"> hx-trigger="load, toast from:window, toasts from:window">
</div> </div>

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add installment plan' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'installment_plan_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit installment plan' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'installment_plan_edit' installment_plan_id=installment_plan.id %}"
hx-target="#generic-offcanvas"
novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -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 'Installment Plans' %}<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 'installment_plan_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 installment_plan in installment_plans %}
<tr class="installment-plan">
<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 'installment_plan_edit' installment_plan_id=installment_plan.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 "Installments" %}"
hx-get="{% url 'installment_plan_transactions' installment_plan_id=installment_plan.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 'installment_plan_delete' installment_plan_id=installment_plan.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "This will delete the plan 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">{{ installment_plan.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@@ -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 'installment_plan_transactions' installment_plan_id=installment_plan.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 %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Installment Plans' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'installment_plans_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}

View File

@@ -22,7 +22,6 @@
{% block extra_js_head %}{% endblock %} {% block extra_js_head %}{% endblock %}
</head> </head>
<body class="font-monospace"> <body class="font-monospace">
<div _="install hide_amounts <div _="install hide_amounts
install htmx_error_handler install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"> {% block body_hyperscript %}{% endblock %}">
@@ -37,11 +36,11 @@
<div id="content"> <div id="content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
</div> </div>
{% include 'includes/offcanvas.html' %}
{% include 'includes/toasts.html' %}
{% block extra_js_body %}{% endblock %} {% block extra_js_body %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -67,7 +67,7 @@
{% translate "Expense" %} {% translate "Expense" %}
</button> </button>
<button class="btn btn-sm btn-outline-warning" <button class="btn btn-sm btn-outline-warning"
hx-get="{% url 'installments_add' %}" hx-get="{% url 'installment_plan_add' %}"
hx-trigger="click, installment from:window" hx-trigger="click, installment from:window"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-divide me-2"></i> <i class="fa-solid fa-divide me-2"></i>
@@ -94,7 +94,7 @@
{# Monthly summary#} {# Monthly summary#}
<div class="row gx-xl-4 gy-3"> <div class="row gx-xl-4 gy-3">
<div class="col-12 col-xl-4 order-0 order-xl-2"> <div class="col-12 col-xl-4 order-0 order-xl-2">
<div id="summary" hx-get="{% url 'monthly_summary' month=month year=year %}" class="sticky-sidebar" <div id="summary" hx-get="{% url 'monthly_summary' month=month year=year %}" class="show-loading"
hx-trigger="load, updated from:window, monthly_summary_update from:window"> hx-trigger="load, updated from:window, monthly_summary_update from:window">
</div> </div>
</div> </div>
@@ -117,8 +117,9 @@
</div> </div>
</div> </div>
<div id="transactions" <div id="transactions"
hx-get="{% url 'monthly_transactions_list' month=month year=year %}" class="show-loading"
hx-trigger="load, updated from:window" hx-include="#filter"></div> hx-get="{% url 'monthly_transactions_list' month=month year=year %}"
hx-trigger="load, updated from:window" hx-include="#filter"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,4 @@
{% extends "layouts/base.html" %}
{% load i18n %} {% load i18n %}
{% block title %}{% translate 'Tags' %}{% endblock %}
{% block content %}
<div class="container px-md-3 py-3 column-gap-5"> <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"> <div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %} {% spaceless %}
@@ -20,8 +15,8 @@
{% endspaceless %} {% endspaceless %}
</div> </div>
<div class="border p-3 rounded-3"> <div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover table-responsive"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
@@ -32,14 +27,14 @@
{% for tag in tags %} {% for tag in tags %}
<tr class="tag"> <tr class="tag">
<td class="col-auto"> <td class="col-auto">
<a class="text-decoration-none tw-text-gray-400 p-1 tag-action" <a class="text-decoration-none tw-text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'tag_edit' tag_id=tag.id %}" hx-get="{% url 'tag_edit' tag_id=tag.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="text-danger text-decoration-none p-1 tag-action" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
@@ -57,4 +52,3 @@
</table> </table>
</div> </div>
</div> </div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Tags' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'tags_list' %}" hx-trigger="load, updated from:window" class="show-loading mx-5"></div>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block title %}{% translate 'Add Installment Plan' %}{% endblock %} {% block title %}{% translate 'Add Installment Plan' %}{% endblock %}
{% block body %} {% block body %}
<form hx-post="{% url 'installments_add' %}" hx-target="#generic-offcanvas" novalidate> <form hx-post="{% url 'installment_plan_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %} {% crispy form %}
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -1,9 +1,11 @@
{% load i18n %} {% load i18n %}
{% load currency_display %} {% load currency_display %}
<div class="transaction d-flex my-3"> <div class="transaction d-flex my-3">
{% if not disable_selection %}
<label class="px-3 d-flex align-items-center justify-content-center"> <label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" aria-label="{% translate 'Select' %}"> <input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" aria-label="{% translate 'Select' %}">
</label> </label>
{% endif %}
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom <div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
hover:tw-bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw-border-dashed{% else %}tw-border-solid{% endif %} hover:tw-bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw-border-dashed{% else %}tw-border-solid{% endif %}
{% if transaction.type == "EX" %}tw-border-red-500{% else %}tw-border-green-500{% endif %} tw-relative {% if transaction.type == "EX" %}tw-border-red-500{% else %}tw-border-green-500{% endif %} tw-relative
@@ -25,7 +27,14 @@
{# Date#} {# Date#}
<div class="mb-1 tw-text-gray-400"> <div class="mb-1 tw-text-gray-400">
<i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i>{{ transaction.date|date:"SHORT_DATE_FORMAT" }}</div> <i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i>{{ transaction.date|date:"SHORT_DATE_FORMAT" }}</div>
<div class="mb-1 text-white tw-text-base">{{ transaction.description }}</div> <div class="mb-1 text-white tw-text-base">
{% spaceless %}
<span>{{ transaction.description }}</span>
{% if transaction.installment_plan and transaction.installment_id %}
<span class="badge text-bg-secondary ms-2">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
{% endif %}
{% endspaceless %}
</div>
<div class="tw-text-gray-400 tw-text-sm"> <div class="tw-text-gray-400 tw-text-sm">
{# Notes#} {# Notes#}
{% if transaction.notes %} {% if transaction.notes %}
@@ -71,25 +80,27 @@
{# Item actions#} {# Item actions#}
<div class="transaction-actions !tw-absolute tw-left-2/4 tw--top-6 tw-invisible d-none <div class="transaction-actions !tw-absolute tw-left-2/4 tw--top-6 tw-invisible d-none
d-xl-flex flex-xl-row tw-text-base card"> d-xl-flex flex-xl-row tw-text-base card">
<a class="text-decoration-none tw-text-gray-400 p-2 transaction-action" <div class="card-body p-1 shadow-lg">
<a class="btn btn-secondary btn-sm transaction-action"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}" hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a> <i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="text-danger text-decoration-none p-2 transaction-action" <a class="btn btn-secondary btn-sm transaction-action"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}" hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a> </a>
</div>
</div> </div>
{# Item actions dropdown fallback for mobile#} {# Item actions dropdown fallback for mobile#}
<div class="dropdown !tw-absolute tw-top-0 tw-right-0 xl:tw-invisible"> <div class="dropdown !tw-absolute tw-top-0 tw-right-0 xl:tw-invisible">