mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-05-16 12:47:13 +02:00
Compare commits
87 Commits
0.16.0
...
internal_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d7dd622f5 | ||
|
|
f2abeff31a | ||
|
|
666eaff167 | ||
|
|
d72454f854 | ||
|
|
333aa81923 | ||
|
|
41b8cfd1e7 | ||
|
|
1fa7985b01 | ||
|
|
38392a6322 | ||
|
|
637c62319b | ||
|
|
f91fe67629 | ||
|
|
9eb1818a20 | ||
|
|
50ac679e33 | ||
|
|
2a463c63b8 | ||
|
|
dce65f2faf | ||
|
|
a053cb3947 | ||
|
|
2d43072120 | ||
|
|
70bdee065e | ||
|
|
95db27a32f | ||
|
|
d6d4e6a102 | ||
|
|
bc0f30fead | ||
|
|
a9a86fc491 | ||
|
|
c3b5f2bf39 | ||
|
|
19128e5aed | ||
|
|
9b5c6d3413 | ||
|
|
73c873a2ad | ||
|
|
9d2be22a77 | ||
|
|
6a3d31f37d | ||
|
|
3be3a3c14b | ||
|
|
a5b0f4efb7 | ||
|
|
6da50db417 | ||
|
|
a6c1daf902 | ||
|
|
6a271fb3d7 | ||
|
|
2cf9a9dd0f | ||
|
|
64b32316ca | ||
|
|
0deaabe719 | ||
|
|
b14342af2e | ||
|
|
efe020efb3 | ||
|
|
2c14ce6366 | ||
|
|
8c133f92ce | ||
|
|
2dd887b0d9 | ||
|
|
f3c9d8faea | ||
|
|
8be7758dc0 | ||
|
|
8f5204a17b | ||
|
|
05dd782df5 | ||
|
|
187fe43283 | ||
|
|
cae73376db | ||
|
|
7225454a6e | ||
|
|
70c8c1e07c | ||
|
|
2235bdeabb | ||
|
|
d724300513 | ||
|
|
eacafa1def | ||
|
|
c738f5ee29 | ||
|
|
c392a2c988 | ||
|
|
17ea859fd2 | ||
|
|
8aae6f928f | ||
|
|
7c43b06b9f | ||
|
|
72904266bf | ||
|
|
e16e279911 | ||
|
|
670bee4325 | ||
|
|
3e2c1184ce | ||
|
|
731f351eef | ||
|
|
b7056e7aa1 | ||
|
|
accceed630 | ||
|
|
76346cb503 | ||
|
|
3df8952ea2 | ||
|
|
9bd067da96 | ||
|
|
1abe9e9f62 | ||
|
|
1a86b5dea4 | ||
|
|
8f2f5a16c2 | ||
|
|
4565dc770b | ||
|
|
23673def09 | ||
|
|
dd2b9ead7e | ||
|
|
2078e9f3e4 | ||
|
|
e6bab57ab4 | ||
|
|
38d50a78f4 | ||
|
|
0d947f9ba6 | ||
|
|
99c85a56bb | ||
|
|
ab1c074f27 | ||
|
|
abf3a148cc | ||
|
|
2733c92da5 | ||
|
|
9bfbe54ed5 | ||
|
|
5b27dea07c | ||
|
|
791e1000a3 | ||
|
|
7301d9f475 | ||
|
|
47a44e96f8 | ||
|
|
7d247eb737 | ||
|
|
373616e7bb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -160,3 +160,6 @@ cython_debug/
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
postgres_data/
|
||||||
|
.prod.env
|
||||||
@@ -126,6 +126,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
|||||||
|
|
||||||
| variable | type | default | explanation |
|
| variable | type | default | explanation |
|
||||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
|
||||||
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
|
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
|
||||||
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
|
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
|
||||||
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
|
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ DEBUG_TOOLBAR_PANELS = [
|
|||||||
"debug_toolbar.panels.signals.SignalsPanel",
|
"debug_toolbar.panels.signals.SignalsPanel",
|
||||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
"cachalot.panels.CachalotPanel",
|
# "cachalot.panels.CachalotPanel",
|
||||||
]
|
]
|
||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
|
|||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Column, Row
|
from crispy_forms.layout import Layout, Field, Column, Row
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
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
|
||||||
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
|||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
class AccountGroupForm(forms.ModelForm):
|
class AccountGroupForm(forms.ModelForm):
|
||||||
@@ -79,6 +81,18 @@ class AccountForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
|
if self.instance.id:
|
||||||
|
qs = Currency.objects.filter(
|
||||||
|
Q(is_archived=False) | Q(accounts=self.instance.id)
|
||||||
|
).distinct()
|
||||||
|
self.fields["currency"].queryset = qs
|
||||||
|
self.fields["exchange_currency"].queryset = qs
|
||||||
|
|
||||||
|
else:
|
||||||
|
qs = Currency.objects.filter(Q(is_archived=False))
|
||||||
|
self.fields["currency"].queryset = qs
|
||||||
|
self.fields["exchange_currency"].queryset = qs
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
|||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
def partial_update(self, request, *args, **kwargs):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
kwargs["partial"] = True
|
kwargs["partial"] = True
|
||||||
|
|||||||
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
|
|||||||
:param decimal_places: The number of decimal places to keep
|
:param decimal_places: The number of decimal places to keep
|
||||||
:return: Truncated Decimal value
|
:return: Truncated Decimal value
|
||||||
"""
|
"""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
value = Decimal(str(value))
|
||||||
|
|
||||||
multiplier = Decimal(10**decimal_places)
|
multiplier = Decimal(10**decimal_places)
|
||||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def remove_old_jobs(context, timestamp):
|
|||||||
return await builtin_tasks.remove_old_jobs(
|
return await builtin_tasks.remove_old_jobs(
|
||||||
context,
|
context,
|
||||||
max_hours=744,
|
max_hours=744,
|
||||||
remove_error=True,
|
remove_failed=True,
|
||||||
remove_cancelled=True,
|
remove_cancelled=True,
|
||||||
remove_aborted=True,
|
remove_aborted=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
|||||||
{
|
{
|
||||||
"x-data": "",
|
"x-data": "",
|
||||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||||
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
|
"x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
"suffix",
|
"suffix",
|
||||||
"code",
|
"code",
|
||||||
"exchange_currency",
|
"exchange_currency",
|
||||||
|
"is_archived",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"exchange_currency": TomSelect(),
|
"exchange_currency": TomSelect(),
|
||||||
@@ -40,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"code",
|
"code",
|
||||||
"name",
|
"name",
|
||||||
|
Switch("is_archived"),
|
||||||
"decimal_places",
|
"decimal_places",
|
||||||
"prefix",
|
"prefix",
|
||||||
"suffix",
|
"suffix",
|
||||||
|
|||||||
18
app/apps/currencies/migrations/0022_currency_is_archived.py
Normal file
18
app/apps/currencies/migrations/0022_currency_is_archived.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-30 00:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0021_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='currency',
|
||||||
|
name='is_archived',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Archived'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -32,6 +32,11 @@ class Currency(models.Model):
|
|||||||
help_text=_("Default currency for exchange calculations"),
|
help_text=_("Default currency for exchange calculations"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_archived = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Archived"),
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
currency.full_clean()
|
currency.full_clean()
|
||||||
|
|
||||||
def test_currency_unique_code(self):
|
|
||||||
"""Test that currency codes must be unique"""
|
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
|
|
||||||
|
|
||||||
def test_currency_unique_name(self):
|
def test_currency_unique_name(self):
|
||||||
"""Test that currency names must be unique"""
|
"""Test that currency names must be unique"""
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ from apps.currencies.utils.convert import convert
|
|||||||
def get_categories_totals(
|
def get_categories_totals(
|
||||||
transactions_queryset, ignore_empty=False, show_entities=False
|
transactions_queryset, ignore_empty=False, show_entities=False
|
||||||
):
|
):
|
||||||
# First get the category totals as before
|
# Step 1: Aggregate transaction data by category and currency.
|
||||||
|
# This query calculates the total current and projected income/expense for each
|
||||||
|
# category by grouping transactions and summing up their amounts based on their
|
||||||
|
# type (income/expense) and payment status (paid/unpaid).
|
||||||
category_currency_metrics = (
|
category_currency_metrics = (
|
||||||
transactions_queryset.values(
|
transactions_queryset.values(
|
||||||
"category",
|
"category",
|
||||||
@@ -76,7 +79,10 @@ def get_categories_totals(
|
|||||||
.order_by("category__name")
|
.order_by("category__name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get tag totals within each category with currency details
|
# Step 2: Aggregate transaction data by tag, category, and currency.
|
||||||
|
# This is similar to the category metrics but adds tags to the grouping,
|
||||||
|
# allowing for a breakdown of totals by tag within each category. It also
|
||||||
|
# handles untagged transactions, where the 'tags' field is None.
|
||||||
tag_metrics = transactions_queryset.values(
|
tag_metrics = transactions_queryset.values(
|
||||||
"category",
|
"category",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -131,10 +137,12 @@ def get_categories_totals(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the results to structure by category
|
# Step 3: Initialize the main dictionary to structure the final results.
|
||||||
|
# The data will be organized hierarchically: category -> currency -> tags -> entities.
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
# Process category totals first
|
# Step 4: Process the aggregated category metrics to build the initial result structure.
|
||||||
|
# This loop iterates through each category's metrics and populates the `result` dict.
|
||||||
for metric in category_currency_metrics:
|
for metric in category_currency_metrics:
|
||||||
# Skip empty categories if ignore_empty is True
|
# Skip empty categories if ignore_empty is True
|
||||||
if ignore_empty and all(
|
if ignore_empty and all(
|
||||||
@@ -185,7 +193,7 @@ def get_categories_totals(
|
|||||||
"total_final": total_final,
|
"total_final": total_final,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchanged values if exchange_currency exists
|
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
|
||||||
if metric["account__currency__exchange_currency"]:
|
if metric["account__currency__exchange_currency"]:
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
exchange_currency = Currency.objects.get(
|
exchange_currency = Currency.objects.get(
|
||||||
@@ -224,7 +232,7 @@ def get_categories_totals(
|
|||||||
|
|
||||||
result[category_id]["currencies"][currency_id] = currency_data
|
result[category_id]["currencies"][currency_id] = currency_data
|
||||||
|
|
||||||
# Process tag totals and add them to the result, including untagged
|
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
|
||||||
for tag_metric in tag_metrics:
|
for tag_metric in tag_metrics:
|
||||||
category_id = tag_metric["category"]
|
category_id = tag_metric["category"]
|
||||||
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
||||||
@@ -281,7 +289,7 @@ def get_categories_totals(
|
|||||||
"total_final": tag_total_final,
|
"total_final": tag_total_final,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchange currency support for tags
|
# Step 5a: Handle currency conversion for tag totals.
|
||||||
if tag_metric["account__currency__exchange_currency"]:
|
if tag_metric["account__currency__exchange_currency"]:
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
exchange_currency = Currency.objects.get(
|
exchange_currency = Currency.objects.get(
|
||||||
@@ -322,6 +330,7 @@ def get_categories_totals(
|
|||||||
currency_id
|
currency_id
|
||||||
] = tag_currency_data
|
] = tag_currency_data
|
||||||
|
|
||||||
|
# Step 6: If requested, aggregate and process entity-level data.
|
||||||
if show_entities:
|
if show_entities:
|
||||||
entity_metrics = transactions_queryset.values(
|
entity_metrics = transactions_queryset.values(
|
||||||
"category",
|
"category",
|
||||||
@@ -389,14 +398,15 @@ def get_categories_totals(
|
|||||||
tag_id = entity_metric["tags"]
|
tag_id = entity_metric["tags"]
|
||||||
entity_id = entity_metric["entities"]
|
entity_id = entity_metric["entities"]
|
||||||
|
|
||||||
if not entity_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if category_id in result:
|
if category_id in result:
|
||||||
tag_key = tag_id if tag_id is not None else "untagged"
|
tag_key = tag_id if tag_id is not None else "untagged"
|
||||||
if tag_key in result[category_id]["tags"]:
|
if tag_key in result[category_id]["tags"]:
|
||||||
entity_key = entity_id
|
entity_key = entity_id if entity_id is not None else "no_entity"
|
||||||
entity_name = entity_metric["entities__name"]
|
entity_name = (
|
||||||
|
entity_metric["entities__name"]
|
||||||
|
if entity_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
if "entities" not in result[category_id]["tags"][tag_key]:
|
if "entities" not in result[category_id]["tags"][tag_key]:
|
||||||
result[category_id]["tags"][tag_key]["entities"] = {}
|
result[category_id]["tags"][tag_key]["entities"] = {}
|
||||||
|
|||||||
@@ -102,4 +102,6 @@ def get_transactions(
|
|||||||
account__in=request.user.untracked_accounts.all()
|
account__in=request.user.untracked_accounts.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
transactions = transactions.exclude(account__currency__is_archived=True)
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def calculate_historical_currency_net_worth(queryset):
|
|||||||
| Q(accounts__visibility="private", accounts__owner=None),
|
| Q(accounts__visibility="private", accounts__owner=None),
|
||||||
accounts__is_archived=False,
|
accounts__is_archived=False,
|
||||||
accounts__isnull=False,
|
accounts__isnull=False,
|
||||||
|
is_archived=False,
|
||||||
)
|
)
|
||||||
.values_list("name", flat=True)
|
.values_list("name", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Row, Column
|
from crispy_forms.layout import Layout, Field, Row, Column, HTML
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||||
from apps.rules.models import TransactionRuleAction
|
from apps.rules.models import TransactionRuleAction
|
||||||
|
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||||
|
from apps.transactions.forms import BulkEditTransactionForm
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class TransactionRuleForm(forms.ModelForm):
|
class TransactionRuleForm(forms.ModelForm):
|
||||||
@@ -40,6 +44,8 @@ class TransactionRuleForm(forms.ModelForm):
|
|||||||
Column(Switch("on_create")),
|
Column(Switch("on_create")),
|
||||||
Column(Switch("on_delete")),
|
Column(Switch("on_delete")),
|
||||||
),
|
),
|
||||||
|
"order",
|
||||||
|
Switch("sequenced"),
|
||||||
"description",
|
"description",
|
||||||
"trigger",
|
"trigger",
|
||||||
)
|
)
|
||||||
@@ -65,10 +71,11 @@ class TransactionRuleForm(forms.ModelForm):
|
|||||||
class TransactionRuleActionForm(forms.ModelForm):
|
class TransactionRuleActionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionRuleAction
|
model = TransactionRuleAction
|
||||||
fields = ("value", "field")
|
fields = ("value", "field", "order")
|
||||||
labels = {
|
labels = {
|
||||||
"field": _("Set field"),
|
"field": _("Set field"),
|
||||||
"value": _("To"),
|
"value": _("To"),
|
||||||
|
"order": _("Order"),
|
||||||
}
|
}
|
||||||
widgets = {"field": TomSelect(clear_button=False)}
|
widgets = {"field": TomSelect(clear_button=False)}
|
||||||
|
|
||||||
@@ -82,6 +89,7 @@ class TransactionRuleActionForm(forms.ModelForm):
|
|||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
# TO-DO: Add helper with available commands
|
# TO-DO: Add helper with available commands
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
"order",
|
||||||
"field",
|
"field",
|
||||||
"value",
|
"value",
|
||||||
)
|
)
|
||||||
@@ -147,9 +155,11 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_category_operator": TomSelect(clear_button=False),
|
"search_category_operator": TomSelect(clear_button=False),
|
||||||
"search_internal_note_operator": TomSelect(clear_button=False),
|
"search_internal_note_operator": TomSelect(clear_button=False),
|
||||||
"search_internal_id_operator": TomSelect(clear_button=False),
|
"search_internal_id_operator": TomSelect(clear_button=False),
|
||||||
|
"search_mute_operator": TomSelect(clear_button=False),
|
||||||
}
|
}
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
|
"order": _("Order"),
|
||||||
"search_account_operator": _("Operator"),
|
"search_account_operator": _("Operator"),
|
||||||
"search_type_operator": _("Operator"),
|
"search_type_operator": _("Operator"),
|
||||||
"search_is_paid_operator": _("Operator"),
|
"search_is_paid_operator": _("Operator"),
|
||||||
@@ -163,6 +173,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_internal_id_operator": _("Operator"),
|
"search_internal_id_operator": _("Operator"),
|
||||||
"search_tags_operator": _("Operator"),
|
"search_tags_operator": _("Operator"),
|
||||||
"search_entities_operator": _("Operator"),
|
"search_entities_operator": _("Operator"),
|
||||||
|
"search_mute_operator": _("Operator"),
|
||||||
"search_account": _("Account"),
|
"search_account": _("Account"),
|
||||||
"search_type": _("Type"),
|
"search_type": _("Type"),
|
||||||
"search_is_paid": _("Paid"),
|
"search_is_paid": _("Paid"),
|
||||||
@@ -176,6 +187,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_internal_id": _("Internal ID"),
|
"search_internal_id": _("Internal ID"),
|
||||||
"search_tags": _("Tags"),
|
"search_tags": _("Tags"),
|
||||||
"search_entities": _("Entities"),
|
"search_entities": _("Entities"),
|
||||||
|
"search_mute": _("Mute"),
|
||||||
"set_account": _("Account"),
|
"set_account": _("Account"),
|
||||||
"set_type": _("Type"),
|
"set_type": _("Type"),
|
||||||
"set_is_paid": _("Paid"),
|
"set_is_paid": _("Paid"),
|
||||||
@@ -189,6 +201,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"set_category": _("Category"),
|
"set_category": _("Category"),
|
||||||
"set_internal_note": _("Internal Note"),
|
"set_internal_note": _("Internal Note"),
|
||||||
"set_internal_id": _("Internal ID"),
|
"set_internal_id": _("Internal ID"),
|
||||||
|
"set_mute": _("Mute"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -200,6 +213,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
|
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
"order",
|
||||||
BS5Accordion(
|
BS5Accordion(
|
||||||
AccordionGroup(
|
AccordionGroup(
|
||||||
_("Search Criteria"),
|
_("Search Criteria"),
|
||||||
@@ -224,6 +238,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
css_class="form-group col-md-8",
|
css_class="form-group col-md-8",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Field("search_mute_operator"),
|
||||||
|
css_class="form-group col-md-4",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Field("search_mute", rows=1),
|
||||||
|
css_class="form-group col-md-8",
|
||||||
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
Column(
|
Column(
|
||||||
Field("search_account_operator"),
|
Field("search_account_operator"),
|
||||||
@@ -340,6 +364,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
_("Set Values"),
|
_("Set Values"),
|
||||||
Field("set_type", rows=1),
|
Field("set_type", rows=1),
|
||||||
Field("set_is_paid", rows=1),
|
Field("set_is_paid", rows=1),
|
||||||
|
Field("set_mute", rows=1),
|
||||||
Field("set_account", rows=1),
|
Field("set_account", rows=1),
|
||||||
Field("set_entities", rows=1),
|
Field("set_entities", rows=1),
|
||||||
Field("set_date", rows=1),
|
Field("set_date", rows=1),
|
||||||
@@ -381,3 +406,112 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
if commit:
|
if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunCreatedTransacion(forms.Form):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"transaction",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunDeletedTransacion(forms.Form):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"transaction",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper.layout.insert(0, "transaction")
|
||||||
|
self.helper.layout.insert(1, HTML("<hr/>"))
|
||||||
|
|
||||||
|
# Change submit button
|
||||||
|
self.helper.layout[-1] = FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-08-30 18:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("rules", "0014_alter_transactionrule_owner_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="transactionruleaction",
|
||||||
|
options={
|
||||||
|
"ordering": ["order"],
|
||||||
|
"verbose_name": "Edit transaction action",
|
||||||
|
"verbose_name_plural": "Edit transaction actions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="updateorcreatetransactionruleaction",
|
||||||
|
options={
|
||||||
|
"ordering": ["order"],
|
||||||
|
"verbose_name": "Update or create transaction action",
|
||||||
|
"verbose_name_plural": "Update or create transaction actions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="transactionruleaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="updateorcreatetransactionruleaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/apps/rules/migrations/0016_transactionrule_sequenced.py
Normal file
18
app/apps/rules/migrations/0016_transactionrule_sequenced.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-31 18:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0015_alter_transactionruleaction_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='sequenced',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Sequenced'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-31 19:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0016_transactionrule_sequenced'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='search_mute',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Search Mute'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='search_mute_operator',
|
||||||
|
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='set_mute',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Mute'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionruleaction',
|
||||||
|
name='field',
|
||||||
|
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
app/apps/rules/migrations/0018_transactionrule_order.py
Normal file
18
app/apps/rules/migrations/0018_transactionrule_order.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-09-02 14:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='order',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -13,6 +13,11 @@ class TransactionRule(SharedObject):
|
|||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||||
|
sequenced = models.BooleanField(
|
||||||
|
verbose_name=_("Sequenced"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
objects = SharedObjectManager()
|
objects = SharedObjectManager()
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
@@ -32,12 +37,15 @@ class TransactionRuleAction(models.Model):
|
|||||||
is_paid = "is_paid", _("Paid")
|
is_paid = "is_paid", _("Paid")
|
||||||
date = "date", _("Date")
|
date = "date", _("Date")
|
||||||
reference_date = "reference_date", _("Reference Date")
|
reference_date = "reference_date", _("Reference Date")
|
||||||
|
mute = "mute", _("Mute")
|
||||||
amount = "amount", _("Amount")
|
amount = "amount", _("Amount")
|
||||||
description = "description", _("Description")
|
description = "description", _("Description")
|
||||||
notes = "notes", _("Notes")
|
notes = "notes", _("Notes")
|
||||||
category = "category", _("Category")
|
category = "category", _("Category")
|
||||||
tags = "tags", _("Tags")
|
tags = "tags", _("Tags")
|
||||||
entities = "entities", _("Entities")
|
entities = "entities", _("Entities")
|
||||||
|
internal_note = "internal_nome", _("Internal Note")
|
||||||
|
internal_id = "internal_id", _("Internal ID")
|
||||||
|
|
||||||
rule = models.ForeignKey(
|
rule = models.ForeignKey(
|
||||||
TransactionRule,
|
TransactionRule,
|
||||||
@@ -51,6 +59,7 @@ class TransactionRuleAction(models.Model):
|
|||||||
verbose_name=_("Field"),
|
verbose_name=_("Field"),
|
||||||
)
|
)
|
||||||
value = models.TextField(verbose_name=_("Value"))
|
value = models.TextField(verbose_name=_("Value"))
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.rule} - {self.field} - {self.value}"
|
return f"{self.rule} - {self.field} - {self.value}"
|
||||||
@@ -59,6 +68,11 @@ class TransactionRuleAction(models.Model):
|
|||||||
verbose_name = _("Edit transaction action")
|
verbose_name = _("Edit transaction action")
|
||||||
verbose_name_plural = _("Edit transaction actions")
|
verbose_name_plural = _("Edit transaction actions")
|
||||||
unique_together = (("rule", "field"),)
|
unique_together = (("rule", "field"),)
|
||||||
|
ordering = ["order"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_type(self):
|
||||||
|
return "edit_transaction"
|
||||||
|
|
||||||
|
|
||||||
class UpdateOrCreateTransactionRuleAction(models.Model):
|
class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||||
@@ -237,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
verbose_name="Internal ID Operator",
|
verbose_name="Internal ID Operator",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
search_mute = models.TextField(
|
||||||
|
verbose_name="Search Mute",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
search_mute_operator = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=SearchOperator.choices,
|
||||||
|
default=SearchOperator.EXACT,
|
||||||
|
verbose_name="Mute Operator",
|
||||||
|
)
|
||||||
|
|
||||||
# Set fields
|
# Set fields
|
||||||
set_account = models.TextField(
|
set_account = models.TextField(
|
||||||
verbose_name=_("Account"),
|
verbose_name=_("Account"),
|
||||||
@@ -290,10 +315,21 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
verbose_name=_("Tags"),
|
verbose_name=_("Tags"),
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
set_mute = models.TextField(
|
||||||
|
verbose_name=_("Mute"),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Update or create transaction action")
|
verbose_name = _("Update or create transaction action")
|
||||||
verbose_name_plural = _("Update or create transaction actions")
|
verbose_name_plural = _("Update or create transaction actions")
|
||||||
|
ordering = ["order"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_type(self):
|
||||||
|
return "update_or_create_transaction"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Update or create transaction action for {self.rule}"
|
return f"Update or create transaction action for {self.rule}"
|
||||||
@@ -325,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
value = simple.eval(self.search_is_paid)
|
value = simple.eval(self.search_is_paid)
|
||||||
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
||||||
|
|
||||||
|
if self.search_mute:
|
||||||
|
value = simple.eval(self.search_mute)
|
||||||
|
search_query &= add_to_query("mute", value, self.search_mute_operator)
|
||||||
|
|
||||||
if self.search_date:
|
if self.search_date:
|
||||||
value = simple.eval(self.search_date)
|
value = simple.eval(self.search_date)
|
||||||
search_query &= add_to_query("date", value, self.search_date_operator)
|
search_query &= add_to_query("date", value, self.search_date_operator)
|
||||||
|
|||||||
@@ -9,40 +9,17 @@ from apps.transactions.models import (
|
|||||||
)
|
)
|
||||||
from apps.rules.tasks import check_for_transaction_rules
|
from apps.rules.tasks import check_for_transaction_rules
|
||||||
from apps.common.middleware.thread_local import get_current_user
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.rules.utils.transactions import serialize_transaction
|
||||||
|
|
||||||
|
|
||||||
@receiver(transaction_created)
|
@receiver(transaction_created)
|
||||||
@receiver(transaction_updated)
|
@receiver(transaction_updated)
|
||||||
@receiver(transaction_deleted)
|
@receiver(transaction_deleted)
|
||||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||||
|
old_data = kwargs.get("old_data")
|
||||||
if signal is transaction_deleted:
|
if signal is transaction_deleted:
|
||||||
# Serialize transaction data for processing
|
# Serialize transaction data for processing
|
||||||
transaction_data = {
|
transaction_data = serialize_transaction(sender, deleted=True)
|
||||||
"id": sender.id,
|
|
||||||
"account": (sender.account.id, sender.account.name),
|
|
||||||
"account_group": (
|
|
||||||
sender.account.group.id if sender.account.group else None,
|
|
||||||
sender.account.group.name if sender.account.group else None,
|
|
||||||
),
|
|
||||||
"type": str(sender.type),
|
|
||||||
"is_paid": sender.is_paid,
|
|
||||||
"is_asset": sender.account.is_asset,
|
|
||||||
"is_archived": sender.account.is_archived,
|
|
||||||
"category": (
|
|
||||||
sender.category.id if sender.category else None,
|
|
||||||
sender.category.name if sender.category else None,
|
|
||||||
),
|
|
||||||
"date": sender.date.isoformat(),
|
|
||||||
"reference_date": sender.reference_date.isoformat(),
|
|
||||||
"amount": str(sender.amount),
|
|
||||||
"description": sender.description,
|
|
||||||
"notes": sender.notes,
|
|
||||||
"tags": list(sender.tags.values_list("id", "name")),
|
|
||||||
"entities": list(sender.entities.values_list("id", "name")),
|
|
||||||
"deleted": True,
|
|
||||||
"internal_note": sender.internal_note,
|
|
||||||
"internal_id": sender.internal_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
check_for_transaction_rules.defer(
|
check_for_transaction_rules.defer(
|
||||||
transaction_data=transaction_data,
|
transaction_data=transaction_data,
|
||||||
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
|||||||
dca_entry.amount_received = sender.amount
|
dca_entry.amount_received = sender.amount
|
||||||
dca_entry.save()
|
dca_entry.save()
|
||||||
|
|
||||||
|
if signal is transaction_updated and old_data:
|
||||||
|
old_data = serialize_transaction(old_data, deleted=False)
|
||||||
|
|
||||||
check_for_transaction_rules.defer(
|
check_for_transaction_rules.defer(
|
||||||
instance_id=sender.id,
|
instance_id=sender.id,
|
||||||
user_id=get_current_user().id,
|
user_id=get_current_user().id,
|
||||||
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
|||||||
if signal is transaction_created
|
if signal is transaction_created
|
||||||
else "transaction_updated"
|
else "transaction_updated"
|
||||||
),
|
),
|
||||||
|
old_data=old_data,
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,21 @@ urlpatterns = [
|
|||||||
views.transaction_rule_take_ownership,
|
views.transaction_rule_take_ownership,
|
||||||
name="transaction_rule_take_ownership",
|
name="transaction_rule_take_ownership",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/created/",
|
||||||
|
views.dry_run_rule_created,
|
||||||
|
name="transaction_rule_dry_run_created",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/deleted/",
|
||||||
|
views.dry_run_rule_deleted,
|
||||||
|
name="transaction_rule_dry_run_deleted",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/updated/",
|
||||||
|
views.dry_run_rule_updated,
|
||||||
|
name="transaction_rule_dry_run_updated",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"rules/transaction/<int:pk>/share/",
|
"rules/transaction/<int:pk>/share/",
|
||||||
views.transaction_rule_share,
|
views.transaction_rule_share,
|
||||||
|
|||||||
0
app/apps/rules/utils/__init__.py
Normal file
0
app/apps/rules/utils/__init__.py
Normal file
101
app/apps/rules/utils/transactions.py
Normal file
101
app/apps/rules/utils/transactions.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db.models import Sum, Value, DecimalField, Case, When, F
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
from apps.transactions.models import (
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionsGetter:
|
||||||
|
def __init__(self, **filters):
|
||||||
|
self.__queryset = Transaction.objects.filter(**filters)
|
||||||
|
|
||||||
|
def exclude(self, **exclude_filters):
|
||||||
|
self.__queryset = self.__queryset.exclude(**exclude_filters)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sum(self):
|
||||||
|
return self.__queryset.aggregate(
|
||||||
|
total=Coalesce(
|
||||||
|
Sum("amount"), Value(Decimal("0")), output_field=DecimalField()
|
||||||
|
)
|
||||||
|
)["total"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance(self):
|
||||||
|
return abs(
|
||||||
|
self.__queryset.aggregate(
|
||||||
|
balance=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||||
|
default=F("amount"),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(Decimal("0")),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
)["balance"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw_balance(self):
|
||||||
|
return self.__queryset.aggregate(
|
||||||
|
balance=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||||
|
default=F("amount"),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(Decimal("0")),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
)["balance"]
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_transaction(sender: Transaction, deleted: bool):
|
||||||
|
return {
|
||||||
|
"id": sender.id,
|
||||||
|
"account": (sender.account.id, sender.account.name),
|
||||||
|
"account_group": (
|
||||||
|
sender.account.group.id if sender.account.group else None,
|
||||||
|
sender.account.group.name if sender.account.group else None,
|
||||||
|
),
|
||||||
|
"type": str(sender.type),
|
||||||
|
"is_paid": sender.is_paid,
|
||||||
|
"is_asset": sender.account.is_asset,
|
||||||
|
"is_archived": sender.account.is_archived,
|
||||||
|
"category": (
|
||||||
|
sender.category.id if sender.category else None,
|
||||||
|
sender.category.name if sender.category else None,
|
||||||
|
),
|
||||||
|
"date": sender.date.isoformat(),
|
||||||
|
"reference_date": sender.reference_date.isoformat(),
|
||||||
|
"amount": str(sender.amount),
|
||||||
|
"description": sender.description,
|
||||||
|
"notes": sender.notes,
|
||||||
|
"tags": list(sender.tags.values_list("id", "name")),
|
||||||
|
"entities": list(sender.entities.values_list("id", "name")),
|
||||||
|
"deleted": deleted,
|
||||||
|
"internal_note": sender.internal_note,
|
||||||
|
"internal_id": sender.internal_id,
|
||||||
|
"mute": sender.mute,
|
||||||
|
"installment_id": sender.installment_id if sender.installment_plan else None,
|
||||||
|
"installment_total": (
|
||||||
|
sender.installment_plan.number_of_installments
|
||||||
|
if sender.installment_plan is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"installment": sender.installment_plan is not None,
|
||||||
|
"recurring_transaction": sender.recurring_transaction is not None,
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -10,6 +15,9 @@ from apps.rules.forms import (
|
|||||||
TransactionRuleForm,
|
TransactionRuleForm,
|
||||||
TransactionRuleActionForm,
|
TransactionRuleActionForm,
|
||||||
UpdateOrCreateTransactionRuleActionForm,
|
UpdateOrCreateTransactionRuleActionForm,
|
||||||
|
DryRunCreatedTransacion,
|
||||||
|
DryRunDeletedTransacion,
|
||||||
|
DryRunUpdatedTransactionForm,
|
||||||
)
|
)
|
||||||
from apps.rules.models import (
|
from apps.rules.models import (
|
||||||
TransactionRule,
|
TransactionRule,
|
||||||
@@ -19,6 +27,11 @@ from apps.rules.models import (
|
|||||||
from apps.common.models import SharedObject
|
from apps.common.models import SharedObject
|
||||||
from apps.common.forms import SharedObjectForm
|
from apps.common.forms import SharedObjectForm
|
||||||
from apps.common.decorators.demo import disabled_on_demo
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
from apps.rules.tasks import check_for_transaction_rules
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.rules.signals import transaction_created, transaction_updated
|
||||||
|
from apps.rules.utils.transactions import serialize_transaction
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -36,7 +49,7 @@ def rules_index(request):
|
|||||||
@disabled_on_demo
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def rules_list(request):
|
def rules_list(request):
|
||||||
transaction_rules = TransactionRule.objects.all().order_by("id")
|
transaction_rules = TransactionRule.objects.all().order_by("order", "id")
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"rules/fragments/list.html",
|
"rules/fragments/list.html",
|
||||||
@@ -140,10 +153,20 @@ def transaction_rule_edit(request, transaction_rule_id):
|
|||||||
def transaction_rule_view(request, transaction_rule_id):
|
def transaction_rule_view(request, transaction_rule_id):
|
||||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||||
|
|
||||||
|
edit_actions = transaction_rule.transaction_actions.all()
|
||||||
|
update_or_create_actions = (
|
||||||
|
transaction_rule.update_or_create_transaction_actions.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
all_actions = sorted(
|
||||||
|
chain(edit_actions, update_or_create_actions),
|
||||||
|
key=lambda a: a.order,
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"rules/fragments/transaction_rule/view.html",
|
"rules/fragments/transaction_rule/view.html",
|
||||||
{"transaction_rule": transaction_rule},
|
{"transaction_rule": transaction_rule, "all_actions": all_actions},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_created(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunCreatedTransacion(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=form.cleaned_data["transaction"].id,
|
||||||
|
signal="transaction_created",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = DryRunCreatedTransacion()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_deleted(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunDeletedTransacion(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=form.cleaned_data["transaction"].id,
|
||||||
|
signal="transaction_deleted",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = DryRunDeletedTransacion()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/deleted.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_updated(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunUpdatedTransactionForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
base_transaction = Transaction.objects.get(
|
||||||
|
id=request.POST.get("transaction")
|
||||||
|
)
|
||||||
|
old_data = deepcopy(base_transaction)
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
for field_name, value in form.cleaned_data.items():
|
||||||
|
if value or isinstance(
|
||||||
|
value, bool
|
||||||
|
): # Only update fields that have been filled in the form
|
||||||
|
if field_name == "tags":
|
||||||
|
base_transaction.tags.set(value)
|
||||||
|
elif field_name == "entities":
|
||||||
|
base_transaction.entities.set(value)
|
||||||
|
else:
|
||||||
|
setattr(base_transaction, field_name, value)
|
||||||
|
|
||||||
|
base_transaction.save()
|
||||||
|
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=base_transaction.id,
|
||||||
|
signal="transaction_updated",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
old_data=old_data,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs) if logs else ""
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
# This will rollback the transaction
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/updated.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
@@ -239,11 +241,16 @@ class TransactionForm(forms.ModelForm):
|
|||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
is_new = not self.instance.id
|
is_new = not self.instance.id
|
||||||
|
|
||||||
|
if not is_new:
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
|
||||||
|
else:
|
||||||
|
old_data = None
|
||||||
|
|
||||||
instance = super().save(**kwargs)
|
instance = super().save(**kwargs)
|
||||||
if is_new:
|
if is_new:
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
else:
|
else:
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@@ -347,11 +354,6 @@ class QuickTransactionForm(forms.ModelForm):
|
|||||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
|
||||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
|
||||||
css_class="form-row",
|
|
||||||
),
|
|
||||||
"description",
|
"description",
|
||||||
Field("amount", inputmode="decimal"),
|
Field("amount", inputmode="decimal"),
|
||||||
Row(
|
Row(
|
||||||
@@ -387,35 +389,115 @@ class QuickTransactionForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BulkEditTransactionForm(TransactionForm):
|
class BulkEditTransactionForm(forms.Form):
|
||||||
is_paid = forms.NullBooleanField(required=False)
|
type = forms.ChoiceField(
|
||||||
|
choices=(Transaction.Type.choices),
|
||||||
|
required=False,
|
||||||
|
label=_("Type"),
|
||||||
|
)
|
||||||
|
is_paid = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_("Paid"),
|
||||||
|
)
|
||||||
|
account = DynamicModelChoiceField(
|
||||||
|
model=Account,
|
||||||
|
required=False,
|
||||||
|
label=_("Account"),
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
)
|
||||||
|
date = forms.DateField(
|
||||||
|
label=_("Date"),
|
||||||
|
required=False,
|
||||||
|
widget=AirDatePickerInput(clear_button=False),
|
||||||
|
)
|
||||||
|
reference_date = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(),
|
||||||
|
label=_("Reference Date"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
amount = forms.DecimalField(
|
||||||
|
max_digits=42,
|
||||||
|
decimal_places=30,
|
||||||
|
required=False,
|
||||||
|
label=_("Amount"),
|
||||||
|
widget=ArbitraryDecimalDisplayNumberInput(),
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
max_length=500, required=False, label=_("Description")
|
||||||
|
)
|
||||||
|
notes = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 3}),
|
||||||
|
label=_("Notes"),
|
||||||
|
)
|
||||||
|
category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
entities = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionEntity,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Entities"),
|
||||||
|
queryset=TransactionEntity.objects.all(),
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Make all fields optional
|
|
||||||
for field_name, field in self.fields.items():
|
|
||||||
field.required = False
|
|
||||||
|
|
||||||
del self.helper.layout[-1] # Remove button
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
del self.helper.layout[0:2] # Remove type, is_paid field
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
self.helper.layout.insert(
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
0,
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
Field(
|
Field(
|
||||||
"type",
|
"type",
|
||||||
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
||||||
),
|
),
|
||||||
)
|
|
||||||
|
|
||||||
self.helper.layout.insert(
|
|
||||||
1,
|
|
||||||
Field(
|
Field(
|
||||||
"is_paid",
|
"is_paid",
|
||||||
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
||||||
),
|
),
|
||||||
)
|
Row(
|
||||||
|
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||||
self.helper.layout.append(
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||||
|
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
"description",
|
||||||
|
Field("amount", inputmode="decimal"),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
"notes",
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||||
@@ -423,6 +505,9 @@ class BulkEditTransactionForm(TransactionForm):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
|
|
||||||
class TransferForm(forms.Form):
|
class TransferForm(forms.Form):
|
||||||
from_account = forms.ModelChoiceField(
|
from_account = forms.ModelChoiceField(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import decimal
|
||||||
import logging
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -33,13 +35,13 @@ transaction_deleted = Signal()
|
|||||||
|
|
||||||
class SoftDeleteQuerySet(models.QuerySet):
|
class SoftDeleteQuerySet(models.QuerySet):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _emit_signals(instances, created=False):
|
def _emit_signals(instances, created=False, old_data=None):
|
||||||
"""Helper to emit signals for multiple instances"""
|
"""Helper to emit signals for multiple instances"""
|
||||||
for instance in instances:
|
for i, instance in enumerate(instances):
|
||||||
if created:
|
if created:
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
else:
|
else:
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data[i])
|
||||||
|
|
||||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||||
instances = super().bulk_create(objs, **kwargs)
|
instances = super().bulk_create(objs, **kwargs)
|
||||||
@@ -50,22 +52,25 @@ class SoftDeleteQuerySet(models.QuerySet):
|
|||||||
return instances
|
return instances
|
||||||
|
|
||||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||||
|
old_data = deepcopy(objs)
|
||||||
result = super().bulk_update(objs, fields, **kwargs)
|
result = super().bulk_update(objs, fields, **kwargs)
|
||||||
|
|
||||||
if emit_signal:
|
if emit_signal:
|
||||||
self._emit_signals(objs, created=False)
|
self._emit_signals(objs, created=False, old_data=old_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def update(self, emit_signal=True, **kwargs):
|
def update(self, emit_signal=True, **kwargs):
|
||||||
# Get instances before update
|
# Get instances before update
|
||||||
instances = list(self)
|
instances = list(self)
|
||||||
|
old_data = deepcopy(instances)
|
||||||
|
|
||||||
result = super().update(**kwargs)
|
result = super().update(**kwargs)
|
||||||
|
|
||||||
if emit_signal:
|
if emit_signal:
|
||||||
# Refresh instances to get new values
|
# Refresh instances to get new values
|
||||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||||
self._emit_signals(refreshed, created=False)
|
self._emit_signals(refreshed, created=False, old_data=old_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -376,7 +381,10 @@ class Transaction(OwnedObject):
|
|||||||
db_table = "transactions"
|
db_table = "transactions"
|
||||||
default_manager_name = "objects"
|
default_manager_name = "objects"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def clean_fields(self, *args, **kwargs):
|
||||||
|
if isinstance(self.amount, (str, int, float)):
|
||||||
|
self.amount = decimal.Decimal(str(self.amount))
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
@@ -386,6 +394,11 @@ class Transaction(OwnedObject):
|
|||||||
elif not self.reference_date and self.date:
|
elif not self.reference_date and self.date:
|
||||||
self.reference_date = self.date.replace(day=1)
|
self.reference_date = self.date.replace(day=1)
|
||||||
|
|
||||||
|
super().clean_fields(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||||
|
# We only do this here because we forgot to independently call it on multiple places.
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -443,12 +456,58 @@ class Transaction(OwnedObject):
|
|||||||
type_display = self.get_type_display()
|
type_display = self.get_type_display()
|
||||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||||
account = self.account
|
account = self.account
|
||||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
tags = (
|
||||||
|
", ".join([x.name for x in self.tags.all()])
|
||||||
|
if self.id
|
||||||
|
else None or _("No tags")
|
||||||
|
)
|
||||||
category = self.category or _("No category")
|
category = self.category or _("No category")
|
||||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||||
description = self.description or _("No description")
|
description = self.description or _("No description")
|
||||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||||
|
|
||||||
|
def deepcopy(self, memo=None):
|
||||||
|
"""
|
||||||
|
Creates a deep copy of the transaction instance.
|
||||||
|
|
||||||
|
This method returns a new, unsaved Transaction instance with the same
|
||||||
|
values as the original, including its many-to-many relationships.
|
||||||
|
The primary key and any other unique fields are reset to avoid
|
||||||
|
database integrity errors upon saving.
|
||||||
|
"""
|
||||||
|
if memo is None:
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
# Create a new instance of the class
|
||||||
|
new_obj = self.__class__()
|
||||||
|
memo[id(self)] = new_obj
|
||||||
|
|
||||||
|
# Copy all concrete fields from the original to the new object
|
||||||
|
for field in self._meta.concrete_fields:
|
||||||
|
# Skip the primary key to allow the database to generate a new one
|
||||||
|
if field.primary_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reset any unique fields to None to avoid constraint violations
|
||||||
|
if field.unique and field.name == "internal_id":
|
||||||
|
setattr(new_obj, field.name, None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Copy the value of the field
|
||||||
|
setattr(new_obj, field.name, getattr(self, field.name))
|
||||||
|
|
||||||
|
# Save the new object to the database to get a primary key
|
||||||
|
new_obj.save()
|
||||||
|
|
||||||
|
# Copy the many-to-many relationships
|
||||||
|
for field in self._meta.many_to_many:
|
||||||
|
source_manager = getattr(self, field.name)
|
||||||
|
destination_manager = getattr(new_obj, field.name)
|
||||||
|
# Set the M2M relationships for the new object
|
||||||
|
destination_manager.set(source_manager.all())
|
||||||
|
|
||||||
|
return new_obj
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlan(models.Model):
|
class InstallmentPlan(models.Model):
|
||||||
class Recurrence(models.TextChoices):
|
class Recurrence(models.TextChoices):
|
||||||
|
|||||||
@@ -175,6 +175,6 @@ class RecurringTransactionTests(TestCase):
|
|||||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||||
recurrence_interval=1,
|
recurrence_interval=1,
|
||||||
)
|
)
|
||||||
self.assertFalse(recurring.paused)
|
self.assertFalse(recurring.is_paused)
|
||||||
self.assertEqual(recurring.recurrence_interval, 1)
|
self.assertEqual(recurring.recurrence_interval, 1)
|
||||||
self.assertEqual(recurring.account.currency.code, "USD")
|
self.assertEqual(recurring.account.currency.code, "USD")
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ def transactions_bulk_edit(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# Apply changes from the form to all selected transactions
|
# Apply changes from the form to all selected transactions
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
for field_name, value in form.cleaned_data.items():
|
for field_name, value in form.cleaned_data.items():
|
||||||
if value or isinstance(
|
if value or isinstance(
|
||||||
value, bool
|
value, bool
|
||||||
@@ -225,7 +226,7 @@ def transactions_bulk_edit(request):
|
|||||||
setattr(transaction, field_name, value)
|
setattr(transaction, field_name, value)
|
||||||
|
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
@@ -373,10 +374,13 @@ def transactions_transfer(request):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_pay(request, transaction_id):
|
def transaction_pay(request, transaction_id):
|
||||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
new_is_paid = False if transaction.is_paid else True
|
new_is_paid = False if transaction.is_paid else True
|
||||||
transaction.is_paid = new_is_paid
|
transaction.is_paid = new_is_paid
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
@@ -394,11 +398,12 @@ def transaction_pay(request, transaction_id):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_mute(request, transaction_id):
|
def transaction_mute(request, transaction_id):
|
||||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
new_mute = False if transaction.mute else True
|
new_mute = False if transaction.mute else True
|
||||||
transaction.mute = new_mute
|
transaction.mute = new_mute
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
@@ -414,19 +419,20 @@ def transaction_mute(request, transaction_id):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_change_month(request, transaction_id, change_type):
|
def transaction_change_month(request, transaction_id, change_type):
|
||||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
if change_type == "next":
|
if change_type == "next":
|
||||||
transaction.reference_date = transaction.reference_date + relativedelta(
|
transaction.reference_date = transaction.reference_date + relativedelta(
|
||||||
months=1
|
months=1
|
||||||
)
|
)
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
elif change_type == "previous":
|
elif change_type == "previous":
|
||||||
transaction.reference_date = transaction.reference_date - relativedelta(
|
transaction.reference_date = transaction.reference_date - relativedelta(
|
||||||
months=1
|
months=1
|
||||||
)
|
)
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
@@ -440,9 +446,11 @@ def transaction_change_month(request, transaction_id, change_type):
|
|||||||
def transaction_move_to_today(request, transaction_id):
|
def transaction_move_to_today(request, transaction_id):
|
||||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
transaction.date = timezone.localdate(timezone.now())
|
transaction.date = timezone.localdate(timezone.now())
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ urlpatterns = [
|
|||||||
views.toggle_sound_playing,
|
views.toggle_sound_playing,
|
||||||
name="toggle_sound_playing",
|
name="toggle_sound_playing",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"user/toggle-sidebar/",
|
||||||
|
views.toggle_sidebar_status,
|
||||||
|
name="toggle_sidebar_status",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"user/settings/",
|
"user/settings/",
|
||||||
views.update_settings,
|
views.update_settings,
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ def update_settings(request):
|
|||||||
return render(request, "users/fragments/user_settings.html", {"form": form})
|
return render(request, "users/fragments/user_settings.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@htmx_login_required
|
||||||
|
def toggle_sidebar_status(request):
|
||||||
|
if not request.session.get("sidebar_status"):
|
||||||
|
request.session["sidebar_status"] = "floating"
|
||||||
|
|
||||||
|
if request.session["sidebar_status"] == "floating":
|
||||||
|
request.session["sidebar_status"] = "fixed"
|
||||||
|
elif request.session["sidebar_status"] == "fixed":
|
||||||
|
request.session["sidebar_status"] = "floating"
|
||||||
|
else:
|
||||||
|
request.session["sidebar_status"] = "fixed"
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@htmx_login_required
|
@htmx_login_required
|
||||||
@is_superuser
|
@is_superuser
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def yearly_overview_by_currency(request, year: int):
|
|||||||
currency = request.GET.get("currency")
|
currency = request.GET.get("currency")
|
||||||
|
|
||||||
# Base query filter
|
# Base query filter
|
||||||
filter_params = {"reference_date__year": year, "account__is_archived": False}
|
filter_params = {"reference_date__year": year}
|
||||||
|
|
||||||
# Add month filter if provided
|
# Add month filter if provided
|
||||||
if month:
|
if month:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3450
app/locale/id/LC_MESSAGES/django.po
Normal file
3450
app/locale/id/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
3510
app/locale/it/LC_MESSAGES/django.po
Normal file
3510
app/locale/it/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3
app/templates/common/placeholder.html
Normal file
3
app/templates/common/placeholder.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{#This is here so we can add dynamic Tailwind classes that will be required via JS/hyperscript but Tailwind has no knowledge of#}
|
||||||
|
<div class="tw:lg:w-[15vw]"></div>
|
||||||
|
<div class="tw:lg:ml-[16vw]"></div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<li>
|
<li>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="text-muted small fw-bold text-uppercase tw:lg:hidden tw:lg:group-hover:inline me-2">{{ title }}</span>
|
<span class="sidebar-menu-header text-muted small fw-bold text-uppercase me-2">{{ title }}</span>
|
||||||
<hr class="flex-grow-1"/>
|
<hr class="flex-grow-1"/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% load active_link %}
|
{% load active_link %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url url %}"
|
<a href="{% url url %}"
|
||||||
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
class="tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
||||||
{% if tooltip %}
|
{% if tooltip %}
|
||||||
data-bs-placement="right"
|
data-bs-placement="right"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
{% endif %}>
|
{% endif %}>
|
||||||
<i class="{{ icon }} fa-fw"></i>
|
<i class="{{ icon }} fa-fw"></i>
|
||||||
<span
|
<span
|
||||||
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url }}"
|
<a href="{{ url }}"
|
||||||
hx-boost="false"
|
hx-boost="false"
|
||||||
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
class="tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
||||||
{% if tooltip %}
|
{% if tooltip %}
|
||||||
data-bs-placement="right"
|
data-bs-placement="right"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
|
|
||||||
<i class="{{ icon }} fa-fw"></i>
|
<i class="{{ icon }} fa-fw"></i>
|
||||||
<span
|
<span
|
||||||
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% load markdown %}
|
{% load markdown %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
|
<div
|
||||||
|
class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
|
||||||
<div class="d-flex my-1">
|
<div class="d-flex my-1">
|
||||||
{% if not disable_selection %}
|
{% if not disable_selection or not dummy %}
|
||||||
<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 }}"
|
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||||
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||||
@@ -19,9 +20,10 @@
|
|||||||
<a class="text-decoration-none p-3 tw:text-gray-500!"
|
<a class="text-decoration-none p-3 tw:text-gray-500!"
|
||||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||||
role="button"
|
role="button"
|
||||||
|
{% if not dummy %}
|
||||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||||
hx-target="closest .transaction"
|
hx-target="closest .transaction"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML"{% endif %}>
|
||||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||||
class="fa-regular fa-circle"></i>{% endif %}
|
class="fa-regular fa-circle"></i>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
@@ -33,7 +35,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
<div
|
||||||
|
class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||||
{# Date#}
|
{# Date#}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
||||||
@@ -58,14 +61,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tw:text-gray-400 tw:text-sm">
|
<div class="tw:text-gray-400 tw:text-sm">
|
||||||
{# Entities #}
|
{# Entities #}
|
||||||
{% with transaction.entities.all as entities %}
|
{% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
|
||||||
{% if entities %}
|
{% if overriden_entities %}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||||
<div class="col ps-0">{{ entities|join:", " }}</div>
|
<div class="col ps-0">{{ overriden_entities|join:", " }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
|
||||||
|
{% elif transaction.id and transaction.entities.all %}
|
||||||
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
|
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||||
|
<div class="col ps-0">{{ transaction.entities.all|join:", " }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{# Notes#}
|
{# Notes#}
|
||||||
{% if transaction.notes %}
|
{% if transaction.notes %}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
@@ -81,17 +90,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Tags#}
|
{# Tags#}
|
||||||
{% with transaction.tags.all as tags %}
|
{% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
|
||||||
{% if tags %}
|
{% if overriden_tags %}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||||
<div class="col ps-0">{{ tags|join:", " }}</div>
|
<div class="col ps-0">{{ overriden_tags|join:", " }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
{% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
|
||||||
|
{% elif transaction.id and transaction.tags.all %}
|
||||||
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
|
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||||
|
<div class="col ps-0">{{ transaction.tags.all|join:", " }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
<div
|
||||||
|
class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||||
<div class="main-amount mb-2 mb-lg-0">
|
<div class="main-amount mb-2 mb-lg-0">
|
||||||
<c-amount.display
|
<c-amount.display
|
||||||
:amount="transaction.amount"
|
:amount="transaction.amount"
|
||||||
@@ -101,107 +117,136 @@
|
|||||||
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
||||||
</div>
|
</div>
|
||||||
{# Exchange Rate#}
|
{# Exchange Rate#}
|
||||||
{% with exchanged=transaction.exchanged_amount %}
|
{% if not dummy %}
|
||||||
{% if exchanged %}
|
{% with exchanged=transaction.exchanged_amount %}
|
||||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
{% if exchanged %}
|
||||||
<c-amount.display
|
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||||
:amount="exchanged.amount"
|
<c-amount.display
|
||||||
:prefix="exchanged.prefix"
|
:amount="exchanged.amount"
|
||||||
:suffix="exchanged.suffix"
|
:prefix="exchanged.prefix"
|
||||||
:decimal_places="exchanged.decimal_places"
|
:suffix="exchanged.suffix"
|
||||||
color="grey"></c-amount.display>
|
:decimal_places="exchanged.decimal_places"
|
||||||
</div>
|
color="grey"></c-amount.display>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endwith %}
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{% if not dummy %}
|
||||||
{# Item actions#}
|
<div>
|
||||||
<div
|
{# Item actions#}
|
||||||
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
<div
|
||||||
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
|
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
||||||
{% if not transaction.deleted %}
|
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
{% if not transaction.deleted %}
|
||||||
role="button"
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
data-bs-toggle="tooltip"
|
role="button"
|
||||||
data-bs-title="{% translate "Edit" %}"
|
data-bs-toggle="tooltip"
|
||||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
data-bs-title="{% translate "Edit" %}"
|
||||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||||
role="button"
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
data-bs-toggle="tooltip"
|
role="button"
|
||||||
data-bs-title="{% translate "Delete" %}"
|
data-bs-toggle="tooltip"
|
||||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
data-bs-title="{% translate "Delete" %}"
|
||||||
hx-trigger='confirmed'
|
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||||
data-bypass-on-ctrl="true"
|
hx-trigger='confirmed'
|
||||||
data-title="{% translate "Are you sure?" %}"
|
data-bypass-on-ctrl="true"
|
||||||
data-text="{% translate "You won't be able to revert this!" %}"
|
data-title="{% translate "Are you sure?" %}"
|
||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||||
</a>
|
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||||
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
</a>
|
||||||
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
|
||||||
</button>
|
aria-expanded="false">
|
||||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
|
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
||||||
{% if transaction.account.is_untracked_by %}
|
</button>
|
||||||
<li>
|
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
|
||||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
{% if transaction.account.is_untracked_by %}
|
||||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
<li>
|
||||||
<div>
|
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||||
{% translate 'Show on summaries' %}
|
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||||
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
|
<div>
|
||||||
</div>
|
{% translate 'Show on summaries' %}
|
||||||
</a>
|
<div
|
||||||
|
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% elif transaction.category.mute %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||||
|
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||||
|
<div>
|
||||||
|
{% translate 'Show on summaries' %}
|
||||||
|
<div
|
||||||
|
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% elif transaction.mute %}
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
|
||||||
|
hx-target="closest .transaction" hx-swap="outerHTML"><i
|
||||||
|
class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
|
||||||
|
hx-target="closest .transaction" hx-swap="outerHTML"><i
|
||||||
|
class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
|
||||||
|
class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a>
|
||||||
</li>
|
</li>
|
||||||
{% elif transaction.category.mute %}
|
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
<hr class="dropdown-divider">
|
||||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
|
||||||
<div>
|
|
||||||
{% translate 'Show on summaries' %}
|
|
||||||
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
{% elif transaction.mute %}
|
<li><a class="dropdown-item" href="#"
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
|
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i
|
||||||
{% else %}
|
class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a>
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
</li>
|
||||||
{% endif %}
|
<li><a class="dropdown-item" href="#"
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a></li>
|
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i
|
||||||
<li><hr class="dropdown-divider"></li>
|
class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a></li>
|
<li><a class="dropdown-item" href="#"
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
|
hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
|
class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li>
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
|
<hr class="dropdown-divider">
|
||||||
</ul>
|
</li>
|
||||||
{% else %}
|
<li><a class="dropdown-item" href="#"
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i
|
||||||
role="button"
|
class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
|
||||||
data-bs-toggle="tooltip"
|
</ul>
|
||||||
data-bs-title="{% translate "Restore" %}"
|
{% else %}
|
||||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
class="fa-solid fa-trash-arrow-up"></i></a>
|
role="button"
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
data-bs-toggle="tooltip"
|
||||||
role="button"
|
data-bs-title="{% translate "Restore" %}"
|
||||||
data-bs-toggle="tooltip"
|
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||||
data-bs-title="{% translate "Delete" %}"
|
class="fa-solid fa-trash-arrow-up"></i></a>
|
||||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
hx-trigger='confirmed'
|
role="button"
|
||||||
data-bypass-on-ctrl="true"
|
data-bs-toggle="tooltip"
|
||||||
data-title="{% translate "Are you sure?" %}"
|
data-bs-title="{% translate "Delete" %}"
|
||||||
data-text="{% translate "You won't be able to revert this!" %}"
|
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
hx-trigger='confirmed'
|
||||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
data-bypass-on-ctrl="true"
|
||||||
</a>
|
data-title="{% translate "Are you sure?" %}"
|
||||||
{% endif %}
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
|
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||||
|
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<th scope="col" class="col-auto"></th>
|
<th scope="col" class="col-auto"></th>
|
||||||
<th scope="col" class="col-auto">{% translate 'Code' %}</th>
|
<th scope="col" class="col-auto">{% translate 'Code' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||||
|
<th scope="col" class="col">{% translate 'Archived' %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="col-auto">{{ currency.code }}</td>
|
<td class="col-auto">{{ currency.code }}</td>
|
||||||
<td class="col">{{ currency.name }}</td>
|
<td class="col">{{ currency.name }}</td>
|
||||||
|
<td class="col">{% if currency.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -12,6 +12,4 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
|
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
|
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
|
||||||
<meta name="msapplication-TileColor" content="#ffffff">
|
|
||||||
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
|
||||||
<meta name="theme-color" content="#ffffff">
|
|
||||||
|
|||||||
@@ -5,31 +5,48 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="tw:group tw:lg:w-16 tw:lg:hover:w-112 tw:transition-all tw:duration-100 tw:fixed tw:top-0 tw:start-0 tw:h-full tw:z-1020">
|
class="sidebar {% if request.session.sidebar_status == 'floating' %}tw:group sidebar-floating{% elif request.session.sidebar_status == 'fixed' %}sidebar-fixed{% else %}tw:group sidebar-floating{% endif %}"
|
||||||
|
id="sidebar-container">
|
||||||
<nav
|
<nav
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
hx-boost="true"
|
hx-boost="true"
|
||||||
|
hx-swap="transition:true"
|
||||||
data-bs-scroll="true"
|
data-bs-scroll="true"
|
||||||
class="offcanvas-lg offcanvas-start d-lg-flex flex-column position-fixed top-0 start-0 h-100 bg-body-tertiary shadow-sm tw:z-1020 tw:lg:w-16 tw:lg:group-hover:w-104 tw:transition-all tw:duration-100 tw:overflow-hidden">
|
class="offcanvas-lg offcanvas-start d-lg-flex flex-column position-fixed top-0 start-0 h-100 bg-body-tertiary shadow-sm tw:z-1020">
|
||||||
<div
|
|
||||||
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-8 tw:h-full tw:bg-transparent tw:pointer-events-auto tw:z-10"></div>
|
|
||||||
|
|
||||||
<a href="{% url 'index' %}" class="d-none d-lg-flex tw:justify-start p-3 text-decoration-none">
|
<div class="d-none d-lg-flex tw:justify-between tw:items-center tw:border-b tw:border-gray-600 tw:lg:flex">
|
||||||
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
<a href="{% url 'index' %}" class="m-0 d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">
|
||||||
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span>
|
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
||||||
</a>
|
<span class="fs-4 fw-bold ms-3">WYGIWYH</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="sidebar-toggle-btn"
|
||||||
|
class="text-secondary-emphasis tw:rounded-full tw:w-12 tw:h-12 tw:flex tw:items-center tw:justify-center tw:transition-all tw:duration-300"
|
||||||
|
hx-get="{% url 'toggle_sidebar_status' %}"
|
||||||
|
_="on click
|
||||||
|
toggle .sidebar-floating on #sidebar-container
|
||||||
|
toggle .tw\:group on #sidebar-container
|
||||||
|
toggle .sidebar-fixed on #sidebar-container
|
||||||
|
end
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-thumbtack fa-sm"></i>
|
||||||
|
<i class="fa-solid fa-thumbtack-slash fa-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none">
|
<a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none">
|
||||||
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
||||||
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span>
|
<span class="fs-4 fw-bold ms-3">WYGIWYH</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas"
|
<button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas"
|
||||||
aria-label={% translate 'Close' %}></button>
|
aria-label={% translate 'Close' %}></button>
|
||||||
</div>
|
</div>
|
||||||
<hr class="m-0">
|
<hr class="m-0">
|
||||||
|
|
||||||
<ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
|
<ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
|
||||||
style="animation-duration: 100ms">
|
style="animation-duration: 100ms">
|
||||||
|
|
||||||
<c-components.sidebar-menu-item
|
<c-components.sidebar-menu-item
|
||||||
@@ -127,27 +144,26 @@
|
|||||||
data-bs-target="#collapsible-panel"
|
data-bs-target="#collapsible-panel"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="collapsible-panel"
|
aria-controls="collapsible-panel"
|
||||||
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="sidebar-active" %}">
|
class="sidebar-menu-item tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="sidebar-active" %}">
|
||||||
<i class="fa-solid fa-toolbox fa-fw"></i>
|
<i class="fa-solid fa-toolbox fa-fw"></i>
|
||||||
<span
|
<span class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
|
||||||
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
|
{% translate 'Management' %}
|
||||||
{% translate 'Management' %}
|
</span>
|
||||||
</span>
|
<i class="fa-solid fa-chevron-right fa-fw ms-auto pe-2"></i>
|
||||||
</div>
|
</div>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-auto p-2 w-100">
|
<div class="mt-auto p-2 w-100">
|
||||||
<div id="collapsible-panel"
|
<div id="collapsible-panel"
|
||||||
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:max-h-dvh">
|
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:max-h-dvh {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="show" %}">
|
||||||
<div class="tw:h-dvh tw:backdrop-blur-3xl tw:flex tw:flex-col">
|
<div class="tw:h-dvh tw:backdrop-blur-3xl tw:flex tw:flex-col">
|
||||||
<!-- Header -->
|
|
||||||
<div
|
<div
|
||||||
class="tw:flex tw:justify-between tw:items-center tw:p-4 tw:border-b tw:border-gray-600 tw:lg:hidden tw:lg:group-hover:flex">
|
class="tw:justify-between tw:items-center tw:p-4 tw:border-b tw:border-gray-600 sidebar-submenu-header">
|
||||||
<h5 class="tw:text-lg tw:font-semibold tw:text-gray-800 tw:lg:invisible tw:lg:group-hover:visible">
|
<h5 class="tw:text-lg tw:font-semibold tw:text-gray-800 m-0">
|
||||||
{% trans 'Management' %}
|
{% trans 'Management' %}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<button type="button" class="btn-close tw:lg:hidden tw:lg:group-hover:inline" aria-label="Close"
|
<button type="button" class="btn-close" aria-label="Close"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#collapsible-panel"
|
data-bs-target="#collapsible-panel"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
@@ -155,7 +171,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="list-unstyled p-3 d-flex flex-column gap-1 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events] tw:flex-1"
|
<ul class="list-unstyled p-3 d-flex flex-column gap-1 tw:lg:group-hover:animate-[disable-pointer-events] tw:flex-1"
|
||||||
style="animation-duration: 100ms">
|
style="animation-duration: 100ms">
|
||||||
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
||||||
<c-components.sidebar-menu-item
|
<c-components.sidebar-menu-item
|
||||||
@@ -273,17 +289,24 @@
|
|||||||
<div>
|
<div>
|
||||||
<hr class="my-1">
|
<hr class="my-1">
|
||||||
<div
|
<div
|
||||||
class="ps-4 pe-2 py-2 d-flex align-items-center text-body-secondary text-decoration-none justify-content-between tw:text-wrap tw:lg:text-nowrap">
|
class="ps-4 pe-2 py-2 d-flex align-items-center text-decoration-none justify-content-between">
|
||||||
<div>
|
|
||||||
<i class="fa-solid fa-circle-user"></i>
|
<div class="d-flex align-items-center" style="min-width: 0;">
|
||||||
<strong class="ms-2 tw:lg:invisible tw:lg:group-hover:visible">{{ user.email }}</strong>
|
<i class="fa-solid fa-circle-user text-body-secondary"></i>
|
||||||
|
|
||||||
|
<strong class="mx-2 text-body-secondary text-truncate sidebar-invisible">
|
||||||
|
{{ user.email }}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw:lg:invisible tw:lg:group-hover:visible">
|
|
||||||
|
<div class="sidebar-invisible">
|
||||||
{% include 'includes/navbar/user_menu.html' %}
|
{% include 'includes/navbar/user_menu.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-16 tw:h-full tw:bg-transparent"></div>
|
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-16 tw:h-full tw:bg-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -268,95 +268,97 @@
|
|||||||
<!-- Entity rows -->
|
<!-- Entity rows -->
|
||||||
{% if show_entities %}
|
{% if show_entities %}
|
||||||
{% for entity_id, entity in tag.entities.items %}
|
{% for entity_id, entity in tag.entities.items %}
|
||||||
<tr class="table-row-nested-2">
|
{% if entity.name or not entity.name and tag.entities.values|length > 1 %}
|
||||||
<td class="ps-5">
|
<tr class="table-row-nested-2">
|
||||||
<i class="fa-solid fa-user-group fa-fw me-2 text-muted"></i>{{ entity.name }}
|
<td class="ps-5">
|
||||||
</td>
|
<i class="fa-solid fa-user-group fa-fw me-2 text-muted"></i>{% if entity.name %}{{ entity.name }}{% else %}{% trans 'No entity' %}{% endif %}
|
||||||
<td>
|
</td>
|
||||||
{% for currency in entity.currencies.values %}
|
<td>
|
||||||
{% if showing == 'current' and currency.income_current != 0 %}
|
{% for currency in entity.currencies.values %}
|
||||||
<c-amount.display
|
{% if showing == 'current' and currency.income_current != 0 %}
|
||||||
:amount="currency.income_current"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.income_current"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="green"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
color="green"></c-amount.display>
|
||||||
<c-amount.display
|
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
||||||
:amount="currency.income_projected"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.income_projected"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="green"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% elif showing == 'final' and currency.total_income != 0 %}
|
color="green"></c-amount.display>
|
||||||
<c-amount.display
|
{% elif showing == 'final' and currency.total_income != 0 %}
|
||||||
:amount="currency.total_income"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.total_income"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="green"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% else %}
|
color="green"></c-amount.display>
|
||||||
<div>-</div>
|
{% else %}
|
||||||
{% endif %}
|
<div>-</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</td>
|
{% endfor %}
|
||||||
<td>
|
</td>
|
||||||
{% for currency in entity.currencies.values %}
|
<td>
|
||||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
{% for currency in entity.currencies.values %}
|
||||||
<c-amount.display
|
{% if showing == 'current' and currency.expense_current != 0 %}
|
||||||
:amount="currency.expense_current"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.expense_current"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="red"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
color="red"></c-amount.display>
|
||||||
<c-amount.display
|
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
||||||
:amount="currency.expense_projected"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.expense_projected"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="red"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% elif showing == 'final' and currency.total_expense != 0 %}
|
color="red"></c-amount.display>
|
||||||
<c-amount.display
|
{% elif showing == 'final' and currency.total_expense != 0 %}
|
||||||
:amount="currency.total_expense"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.total_expense"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="red"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% else %}
|
color="red"></c-amount.display>
|
||||||
<div>-</div>
|
{% else %}
|
||||||
{% endif %}
|
<div>-</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</td>
|
{% endfor %}
|
||||||
<td>
|
</td>
|
||||||
{% for currency in entity.currencies.values %}
|
<td>
|
||||||
{% if showing == 'current' and currency.total_current != 0 %}
|
{% for currency in entity.currencies.values %}
|
||||||
<c-amount.display
|
{% if showing == 'current' and currency.total_current != 0 %}
|
||||||
:amount="currency.total_current"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.total_current"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||||
<c-amount.display
|
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
||||||
:amount="currency.total_projected"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.total_projected"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% elif showing == 'final' and currency.total_final != 0 %}
|
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||||
<c-amount.display
|
{% elif showing == 'final' and currency.total_final != 0 %}
|
||||||
:amount="currency.total_final"
|
<c-amount.display
|
||||||
:prefix="currency.currency.prefix"
|
:amount="currency.total_final"
|
||||||
:suffix="currency.currency.suffix"
|
:prefix="currency.currency.prefix"
|
||||||
:decimal_places="currency.currency.decimal_places"
|
:suffix="currency.currency.suffix"
|
||||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
:decimal_places="currency.currency.decimal_places"
|
||||||
{% else %}
|
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||||
<div>-</div>
|
{% else %}
|
||||||
{% endif %}
|
<div>-</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</td>
|
{% endfor %}
|
||||||
</tr>
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
{% include 'includes/mobile_navbar.html' %}
|
{% include 'includes/mobile_navbar.html' %}
|
||||||
{% include 'includes/sidebar.html' %}
|
{% include 'includes/sidebar.html' %}
|
||||||
|
|
||||||
<main class="tw:p-4 tw:lg:ml-16">
|
<main class="tw:p-4">
|
||||||
{% settings "DEMO" as demo_mode %}
|
{% settings "DEMO" as demo_mode %}
|
||||||
{% if demo_mode %}
|
{% if demo_mode %}
|
||||||
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
||||||
|
|||||||
@@ -44,12 +44,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Action buttons#}
|
{# Action buttons#}
|
||||||
{# <div class="col-12 col-xl-8">#}
|
{# <div class="col-12 col-xl-8">#}
|
||||||
{# <c-ui.quick-transactions-buttons#}
|
{# <c-ui.quick-transactions-buttons#}
|
||||||
{# :year="year"#}
|
{# :year="year"#}
|
||||||
{# :month="month"#}
|
{# :month="month"#}
|
||||||
{# ></c-ui.quick-transactions-buttons>#}
|
{# ></c-ui.quick-transactions-buttons>#}
|
||||||
{# </div>#}
|
{# </div>#}
|
||||||
</div>
|
</div>
|
||||||
{# Monthly summary#}
|
{# Monthly summary#}
|
||||||
<div class="row gx-xl-4 gy-3">
|
<div class="row gx-xl-4 gy-3">
|
||||||
@@ -133,59 +133,101 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
||||||
<div class="row mb-1">
|
|
||||||
<div class="col-sm-6 col-12">
|
<div class="my-3">
|
||||||
{# Filter transactions button #}
|
{# Hidden select to hold the order value and preserve the original update trigger #}
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button"
|
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
|
||||||
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false"
|
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
||||||
aria-controls="collapse-filter">
|
<option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
||||||
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
|
<option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{# Main control bar with filter, search, and ordering #}
|
||||||
|
<div class="input-group">
|
||||||
|
|
||||||
|
<button class="btn btn-secondary position-relative" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
|
||||||
|
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
|
||||||
|
title="{% translate 'Filter transactions' %}">
|
||||||
|
<i class="fa-solid fa-filter fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
{# Ordering button#}
|
{# Search box #}
|
||||||
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
|
<label for="quick-search">
|
||||||
<div class="text-sm-end" _="on change trigger updated on window">
|
</label>
|
||||||
<label for="order">{% translate "Order by" %}</label>
|
<input type="search"
|
||||||
<select
|
class="form-control"
|
||||||
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
|
placeholder="{% translate 'Search' %}"
|
||||||
name="order" id="order">
|
hx-preserve
|
||||||
<option value="default"
|
|
||||||
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
|
||||||
<option value="older"
|
|
||||||
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
|
||||||
<option value="newer"
|
|
||||||
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{# Filter transactions form#}
|
|
||||||
<div class="collapse" id="collapse-filter" hx-preserve>
|
|
||||||
<div class="card card-body">
|
|
||||||
<form _="on change or submit or search trigger updated on window end
|
|
||||||
install init_tom_select
|
|
||||||
install init_datepicker"
|
|
||||||
id="filter">
|
|
||||||
{% crispy filter.form %}
|
|
||||||
</form>
|
|
||||||
<button class="btn btn-outline-danger btn-sm"
|
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="search" class="my-3">
|
|
||||||
<label class="w-100">
|
|
||||||
<input type="search" class="form-control" placeholder="{% translate 'Search' %}" hx-preserve
|
|
||||||
id="quick-search"
|
id="quick-search"
|
||||||
_="on input or search or htmx:afterSwap from window
|
_="on input or search or htmx:afterSwap from window
|
||||||
if my value is empty
|
if my value is empty
|
||||||
trigger toggle on <.transactions-divider-collapse/>
|
trigger toggle on <.transactions-divider-collapse/>
|
||||||
else
|
else
|
||||||
trigger show on <.transactions-divider-collapse/>
|
trigger show on <.transactions-divider-collapse/>
|
||||||
end
|
end
|
||||||
show <.transactions-divider-title/> when my value is empty
|
show <.transactions-divider-title/> when my value is empty
|
||||||
show <.transaction/> in <#transactions-list/>
|
show <.transaction/> in <#transactions-list/>
|
||||||
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
</label>
|
|
||||||
|
{# Order by icon dropdown #}
|
||||||
|
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-no-icon" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
title="{% translate 'Order by' %}">
|
||||||
|
<i class="fa-solid fa-sort fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'default' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'default'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Default' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'older' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'older'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Oldest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'newer' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'newer'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Newest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Filter transactions form #}
|
||||||
|
<div class="collapse" id="collapse-filter" hx-preserve>
|
||||||
|
<div class="card card-body">
|
||||||
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form _="on change or submit or search trigger updated on window
|
||||||
|
install init_tom_select
|
||||||
|
install init_datepicker"
|
||||||
|
id="filter" class="mt-3">
|
||||||
|
{% crispy filter.form %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Transactions list#}
|
{# Transactions list#}
|
||||||
<div id="transactions"
|
<div id="transactions"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="col-auto"></th>
|
<th scope="col" class="col-auto"></th>
|
||||||
<th scope="col" class="col-auto"></th>
|
<th scope="col" class="col-auto"></th>
|
||||||
|
<th scope="col" class="col-auto">{% translate 'Order' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -80,6 +81,9 @@
|
|||||||
<i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %}
|
<i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="col text-center">
|
||||||
|
<div>{{ rule.order }}</div>
|
||||||
|
</td>
|
||||||
<td class="col">
|
<td class="col">
|
||||||
<div>{{ rule.name }}</div>
|
<div>{{ rule.name }}</div>
|
||||||
<div class="tw:text-gray-400">{{ rule.description }}</div>
|
<div class="tw:text-gray-400">{{ rule.description }}</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Test' %} - {% trans 'Create' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'transaction_rule_dry_run_created' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||||
|
hx-indicator="#dry-run-created-result, closest form" class="show-loading" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="dry-run-created-result" class="show-loading">
|
||||||
|
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Test' %} - {% trans 'Delete' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'transaction_rule_dry_run_deleted' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||||
|
hx-indicator="#dry-run-deleted-result, closest form" class="show-loading" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="dry-run-deleted-result" class="show-loading">
|
||||||
|
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Test' %} - {% trans 'Update' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'transaction_rule_dry_run_updated' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||||
|
hx-indicator="#dry-run-updated-result, closest form" class="show-loading" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="dry-run-updated-result" class="show-loading">
|
||||||
|
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="card tw:max-h-full tw:overflow-auto tw:overflow-x-auto">
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link active" id="visual-tab" data-bs-toggle="tab" data-bs-target="#visual-tab-pane"
|
||||||
|
type="button" role="tab" aria-controls="visual-tab-pane"
|
||||||
|
aria-selected="true">{% translate 'Visual' %}</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs-tab-pane" type="button"
|
||||||
|
role="tab" aria-controls="logs-tab-pane" aria-selected="false">{% translate 'Logs' %}</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content" id="myTabContent">
|
||||||
|
<div class="tab-pane fade show active" id="visual-tab-pane" role="tabpanel" aria-labelledby="home-tab"
|
||||||
|
tabindex="0">
|
||||||
|
{% if not results %}
|
||||||
|
{% translate 'Run a test to see...' %}
|
||||||
|
{% else %}
|
||||||
|
{% for result in results %}
|
||||||
|
|
||||||
|
{% if result.type == 'header' %}
|
||||||
|
<div class="my-3">
|
||||||
|
<h6 class="text-center mb-3">
|
||||||
|
<span class="badge text-bg-secondary">
|
||||||
|
{% if result.header_type == "edit_transaction" %}
|
||||||
|
{% translate 'Edit transaction' %}
|
||||||
|
{% elif result.header_type == "update_or_create_transaction" %}
|
||||||
|
{% translate 'Update or create transaction' %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if result.type == 'triggering_transaction' %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6>
|
||||||
|
<c-transaction.item :transaction="result.transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.type == 'edit_transaction' %}
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{% translate 'Set' %} <span
|
||||||
|
class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %}
|
||||||
|
<span class="badge text-bg-secondary">{{ result.new_value }}</span>
|
||||||
|
</div>
|
||||||
|
<c-transaction.item :transaction="result.transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.type == 'update_or_create_transaction' %}
|
||||||
|
<div>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
{% translate 'Search' %}: {{ result.query }}
|
||||||
|
</div>
|
||||||
|
{% if result.start_transaction %}
|
||||||
|
<c-transaction.item :transaction="result.start_transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{% translate 'No transaction found, a new one will be created' %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-center h3 my-2"><i class="fa-solid fa-arrow-down"></i></div>
|
||||||
|
<c-transaction.item :transaction="result.end_transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.type == 'error' %}
|
||||||
|
<div>
|
||||||
|
<div class="alert alert-{% if result.level == 'error' %}danger{% elif result.level == 'warning' %}warning{% else %}info{% endif %}" role="alert">
|
||||||
|
{{ result.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="logs-tab-pane" role="tabpanel" aria-labelledby="logs-tab" tabindex="0">
|
||||||
|
{% if not logs %}
|
||||||
|
{% translate 'Run a test to see...' %}
|
||||||
|
{% else %}
|
||||||
|
<pre>
|
||||||
|
{{ logs|linebreaks }}
|
||||||
|
</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -30,95 +30,128 @@
|
|||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
|
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
|
||||||
{% for action in transaction_rule.transaction_actions.all %}
|
{% for action in all_actions %}
|
||||||
<div class="card mb-3">
|
{% if action.action_type == "edit_transaction" %}
|
||||||
<div class="card-header">
|
<div class="card mb-3">
|
||||||
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div>
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
|
||||||
|
<span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div>
|
||||||
|
{% translate 'Set' %} <span
|
||||||
|
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
|
||||||
|
</div>
|
||||||
|
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate 'Edit' %}"
|
||||||
|
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
|
||||||
|
hx-target="#generic-offcanvas">
|
||||||
|
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
<a class="text-danger text-decoration-none p-1"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate 'Delete' %}"
|
||||||
|
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
|
||||||
|
hx-trigger='confirmed'
|
||||||
|
data-bypass-on-ctrl="true"
|
||||||
|
data-title="{% translate 'Are you sure?' %}"
|
||||||
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
|
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||||
|
_="install prompt_swal">
|
||||||
|
<i class="fa-solid fa-trash fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
{% elif action.action_type == "update_or_create_transaction" %}
|
||||||
<div>{% translate 'Set' %} <span
|
<div class="card mb-3">
|
||||||
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div>
|
<div class="card-header">
|
||||||
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
|
<div>
|
||||||
|
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
|
||||||
|
<span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div>{% trans 'Edit to view' %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate 'Edit' %}"
|
||||||
|
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
|
||||||
|
hx-target="#generic-offcanvas">
|
||||||
|
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
<a class="text-danger text-decoration-none p-1"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% translate 'Delete' %}"
|
||||||
|
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
|
||||||
|
hx-trigger='confirmed'
|
||||||
|
data-bypass-on-ctrl="true"
|
||||||
|
data-title="{% translate 'Are you sure?' %}"
|
||||||
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
|
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||||
|
_="install prompt_swal">
|
||||||
|
<i class="fa-solid fa-trash fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer text-end">
|
{% endif %}
|
||||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
{% empty %}
|
||||||
role="button"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-title="{% translate "Edit" %}"
|
|
||||||
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
|
|
||||||
hx-target="#generic-offcanvas">
|
|
||||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
|
||||||
</a>
|
|
||||||
<a class="text-danger text-decoration-none p-1"
|
|
||||||
role="button"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-title="{% translate "Delete" %}"
|
|
||||||
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
|
|
||||||
hx-trigger='confirmed'
|
|
||||||
data-bypass-on-ctrl="true"
|
|
||||||
data-title="{% translate "Are you sure?" %}"
|
|
||||||
data-text="{% translate "You won't be able to revert this!" %}"
|
|
||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
|
||||||
_="install prompt_swal">
|
|
||||||
<i class="fa-solid fa-trash fa-fw"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div>{% trans 'Edit to view' %}</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer text-end">
|
|
||||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
|
||||||
role="button"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-title="{% translate "Edit" %}"
|
|
||||||
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
|
|
||||||
hx-target="#generic-offcanvas">
|
|
||||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
|
||||||
</a>
|
|
||||||
<a class="text-danger text-decoration-none p-1"
|
|
||||||
role="button"
|
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-title="{% translate "Delete" %}"
|
|
||||||
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
|
|
||||||
hx-trigger='confirmed'
|
|
||||||
data-bypass-on-ctrl="true"
|
|
||||||
data-title="{% translate "Are you sure?" %}"
|
|
||||||
data-text="{% translate "You won't be able to revert this!" %}"
|
|
||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
|
||||||
_="install prompt_swal">
|
|
||||||
<i class="fa-solid fa-trash fa-fw"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% translate 'This rule has no actions' %}
|
{% translate 'This rule has no actions' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
<hr>
|
<hr>
|
||||||
<div class="dropdown">
|
<div class="d-grid d-lg-flex gap-2">
|
||||||
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
<div class="dropdown flex-fill">
|
||||||
aria-expanded="false">
|
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||||
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
aria-expanded="false">
|
||||||
</button>
|
<i class="fa-solid fa-flask-vial me-2"></i>{% translate 'Test' %}
|
||||||
<ul class="dropdown-menu dropdown-menu-end w-100">
|
</button>
|
||||||
<li><a class="dropdown-item" role="link"
|
<ul class="dropdown-menu">
|
||||||
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
{% if transaction_rule.on_create %}
|
||||||
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
<li><a class="dropdown-item" role="link"
|
hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
|
||||||
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
hx-target="#generic-offcanvas">{% trans 'Create' %}</a></li>
|
||||||
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
|
{% endif %}
|
||||||
</ul>
|
{% if transaction_rule.on_update %}
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'transaction_rule_dry_run_updated' pk=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Update' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if transaction_rule.on_delete %}
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'transaction_rule_dry_run_deleted' pk=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Delete' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown flex-fill">
|
||||||
|
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,121 +5,128 @@
|
|||||||
|
|
||||||
<div id="transactions-list">
|
<div id="transactions-list">
|
||||||
{% for x in transactions_by_date %}
|
{% for x in transactions_by_date %}
|
||||||
<div id="{{ x.grouper|slugify }}"
|
<div id="{{ x.grouper|slugify }}" class="transactions-divider"
|
||||||
_="on htmx:afterSettle from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
|
_="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
|
||||||
<div class="mt-3 mb-1 w-100 tw:text-base border-bottom bg-body">
|
<div class="mt-3 mb-1 w-100 tw:text-base border-bottom bg-body transactions-divider-title">
|
||||||
<a class="text-decoration-none d-inline-block w-100"
|
<a class="text-decoration-none d-inline-block w-100"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
|
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
|
||||||
id="c-{{ x.grouper|slugify }}-collapsible"
|
id="c-{{ x.grouper|slugify }}-collapsible"
|
||||||
aria-expanded="true"
|
aria-expanded="false"
|
||||||
aria-controls="c-{{ x.grouper|slugify }}-collapse">
|
aria-controls="c-{{ x.grouper|slugify }}-collapse">
|
||||||
{{ x.grouper }}
|
{{ x.grouper }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse" id="c-{{ x.grouper|slugify }}-collapse"
|
<div class="collapse transactions-divider-collapse" id="c-{{ x.grouper|slugify }}-collapse"
|
||||||
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
|
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
|
||||||
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
|
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
|
||||||
on htmx:afterSettle from #transactions
|
on htmx:afterSettle from #transactions or toggle
|
||||||
set state to sessionStorage.getItem(the closest parent @id)
|
set state to sessionStorage.getItem(the closest parent @id)
|
||||||
if state is 'true' or state is null
|
if state is 'true' or state is null
|
||||||
add .show to me
|
add .show to me
|
||||||
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
|
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
|
||||||
end">
|
else
|
||||||
|
remove .show from me
|
||||||
|
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
|
||||||
|
end
|
||||||
|
on show
|
||||||
|
add .show to me
|
||||||
|
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
{% for transaction in x.list %}
|
{% for transaction in x.list %}
|
||||||
<c-transaction.item :transaction="transaction"></c-transaction.item>
|
<c-transaction.item
|
||||||
|
:transaction="transaction"></c-transaction.item>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<c-msg.empty
|
<c-msg.empty
|
||||||
title="{% translate "No transactions found" %}"
|
title="{% translate "No transactions found" %}"
|
||||||
subtitle="{% translate "Try adding one" %}"></c-msg.empty>
|
subtitle="{% translate "Try adding one" %}"></c-msg.empty>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page_obj.has_other_pages %}
|
{% if page_obj.has_other_pages %}
|
||||||
<div class="mt-auto">
|
<div class="mt-auto">
|
||||||
<input value="{{ page_obj.number }}" name="page" type="hidden" id="page">
|
<input value="{{ page_obj.number }}" name="page" type="hidden" id="page">
|
||||||
|
|
||||||
<nav aria-label="{% translate 'Page navigation' %}">
|
<nav aria-label="{% translate 'Page navigation' %}">
|
||||||
<ul class="pagination justify-content-center mt-5">
|
<ul class="pagination justify-content-center mt-5">
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link tw:cursor-pointer {% if not page_obj.has_previous %}disabled{% endif %}"
|
<a class="page-link tw:cursor-pointer {% if not page_obj.has_previous %}disabled{% endif %}"
|
||||||
hx-get="{% if page_obj.has_previous %}{% url 'transactions_all_list' %}{% endif %}"
|
hx-get="{% if page_obj.has_previous %}{% url 'transactions_all_list' %}{% endif %}"
|
||||||
hx-vals='{"page": 1}'
|
hx-vals='{"page": 1}'
|
||||||
hx-include="#filter, #order"
|
hx-include="#filter, #order"
|
||||||
hx-target="#transactions-list"
|
hx-target="#transactions-list"
|
||||||
aria-label="Primeira página"
|
aria-label="Primeira página"
|
||||||
hx-swap="show:top">
|
hx-swap="show:top">
|
||||||
<span aria-hidden="true">«</span>
|
<span aria-hidden="true">«</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% for page_number in page_obj.paginator.page_range %}
|
{% for page_number in page_obj.paginator.page_range %}
|
||||||
{% comment %}
|
{% comment %}
|
||||||
This conditional allows us to display up to 3 pages before and after the current page
|
This conditional allows us to display up to 3 pages before and after the current page
|
||||||
If you decide to remove this conditional, all the pages will be displayed
|
If you decide to remove this conditional, all the pages will be displayed
|
||||||
|
|
||||||
You can change the 3 to any number you want e.g
|
You can change the 3 to any number you want e.g
|
||||||
To display only 5 pagination items, change the 3 to 2 (2 before and 2 after the current page)
|
To display only 5 pagination items, change the 3 to 2 (2 before and 2 after the current page)
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
{% if page_number <= page_obj.number|add:3 and page_number >= page_obj.number|add:-3 %}
|
{% if page_number <= page_obj.number|add:3 and page_number >= page_obj.number|add:-3 %}
|
||||||
{% if page_obj.number == page_number %}
|
{% if page_obj.number == page_number %}
|
||||||
<li class="page-item active">
|
<li class="page-item active">
|
||||||
<a class="page-link tw:cursor-pointer">
|
<a class="page-link tw:cursor-pointer">
|
||||||
{{ page_number }}
|
{{ page_number }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
<a class="page-link tw:cursor-pointer"
|
<a class="page-link tw:cursor-pointer"
|
||||||
hx-get="{% url 'transactions_all_list' %}"
|
hx-get="{% url 'transactions_all_list' %}"
|
||||||
hx-vals='{"page": {{ page_number }}}'
|
hx-vals='{"page": {{ page_number }}}'
|
||||||
hx-include="#filter, #order"
|
hx-include="#filter, #order"
|
||||||
hx-target="#transactions-list"
|
hx-target="#transactions-list"
|
||||||
hx-swap="show:top">
|
hx-swap="show:top">
|
||||||
{{ page_number }}
|
{{ page_number }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
{% if page_obj.number|add:3 < page_obj.paginator.num_pages %}
|
{% endfor %}
|
||||||
<li class="page-item">
|
{% if page_obj.number|add:3 < page_obj.paginator.num_pages %}
|
||||||
<a class="page-link disabled"
|
<li class="page-item">
|
||||||
aria-label="...">
|
<a class="page-link disabled"
|
||||||
<span aria-hidden="true">...</span>
|
aria-label="...">
|
||||||
</a>
|
<span aria-hidden="true">...</span>
|
||||||
</li>
|
</a>
|
||||||
<li class="page-item">
|
</li>
|
||||||
<a class="page-link tw:cursor-pointer"
|
<li class="page-item">
|
||||||
hx-get="{% url 'transactions_all_list' %}" hx-target="#transactions-list"
|
<a class="page-link tw:cursor-pointer"
|
||||||
hx-vals='{"page": {{ page_obj.paginator.num_pages }}}'
|
hx-get="{% url 'transactions_all_list' %}" hx-target="#transactions-list"
|
||||||
hx-include="#filter, #order"
|
hx-vals='{"page": {{ page_obj.paginator.num_pages }}}'
|
||||||
hx-swap="show:top"
|
hx-include="#filter, #order"
|
||||||
aria-label="Última página">
|
hx-swap="show:top"
|
||||||
<span aria-hidden="true">{{ page_obj.paginator.num_pages }}</span>
|
aria-label="Última página">
|
||||||
</a>
|
<span aria-hidden="true">{{ page_obj.paginator.num_pages }}</span>
|
||||||
</li>
|
</a>
|
||||||
{% endif %}
|
</li>
|
||||||
<li class="page-item">
|
{% endif %}
|
||||||
<a class="page-link {% if not page_obj.has_next %}disabled{% endif %} tw:cursor-pointer"
|
<li class="page-item">
|
||||||
hx-get="{% if page_obj.has_next %}{% url 'transactions_all_list' %}{% endif %}"
|
<a class="page-link {% if not page_obj.has_next %}disabled{% endif %} tw:cursor-pointer"
|
||||||
hx-vals='{"page": {{ page_obj.paginator.num_pages }}}'
|
hx-get="{% if page_obj.has_next %}{% url 'transactions_all_list' %}{% endif %}"
|
||||||
hx-include="#filter, #order"
|
hx-vals='{"page": {{ page_obj.paginator.num_pages }}}'
|
||||||
hx-swap="show:top"
|
hx-include="#filter, #order"
|
||||||
hx-target="#transactions-list"
|
hx-swap="show:top"
|
||||||
aria-label="Next">
|
hx-target="#transactions-list"
|
||||||
<span aria-hidden="true">»</span>
|
aria-label="Next">
|
||||||
</a>
|
<span aria-hidden="true">»</span>
|
||||||
</li>
|
</a>
|
||||||
</ul>
|
</li>
|
||||||
</nav>
|
</ul>
|
||||||
</div>
|
</nav>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Floating bar#}
|
{# Floating bar#}
|
||||||
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
|
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,45 +8,101 @@
|
|||||||
<div class="container px-md-3 py-3 column-gap-5">
|
<div class="container px-md-3 py-3 column-gap-5">
|
||||||
<div class="row gx-xl-4 gy-3">
|
<div class="row gx-xl-4 gy-3">
|
||||||
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
||||||
<div class="row mb-1">
|
<div class="mb-3">
|
||||||
<div class="col-sm-6 col-12">
|
{# Hidden select to hold the order value and preserve the original update trigger #}
|
||||||
{# Filter transactions button #}
|
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button"
|
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
||||||
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false"
|
<option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
||||||
aria-controls="collapse-filter">
|
<option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
||||||
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
|
</select>
|
||||||
|
|
||||||
|
{# Main control bar with filter, search, and ordering #}
|
||||||
|
<div class="input-group">
|
||||||
|
|
||||||
|
<button class="btn btn-secondary position-relative" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
|
||||||
|
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
|
||||||
|
title="{% translate 'Filter transactions' %}">
|
||||||
|
<i class="fa-solid fa-filter fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{# Search box #}
|
||||||
|
<label for="quick-search">
|
||||||
|
</label>
|
||||||
|
<input type="search"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="{% translate 'Search' %}"
|
||||||
|
hx-preserve
|
||||||
|
id="quick-search"
|
||||||
|
_="on input or search or htmx:afterSwap from window
|
||||||
|
if my value is empty
|
||||||
|
trigger toggle on <.transactions-divider-collapse/>
|
||||||
|
else
|
||||||
|
trigger show on <.transactions-divider-collapse/>
|
||||||
|
end
|
||||||
|
show <.transactions-divider-title/> when my value is empty
|
||||||
|
show <.transaction/> in <#transactions-list/>
|
||||||
|
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
|
|
||||||
|
{# Order by icon dropdown #}
|
||||||
|
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-no-icon" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
title="{% translate 'Order by' %}">
|
||||||
|
<i class="fa-solid fa-sort fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'default' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'default'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Default' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'older' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'older'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Oldest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'newer' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'newer'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Newest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{# Ordering button#}
|
|
||||||
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
|
{# Filter transactions form #}
|
||||||
<div class="text-sm-end" _="on change trigger updated on window">
|
<div class="collapse" id="collapse-filter" hx-preserve>
|
||||||
<label for="order">{% translate "Order by" %}</label>
|
<div class="card card-body">
|
||||||
<select
|
<div class="text-end">
|
||||||
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
name="order" id="order">
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
<option value="default"
|
</div>
|
||||||
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
|
||||||
<option value="older"
|
<form _="on change or submit or search trigger updated on window
|
||||||
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
install init_tom_select
|
||||||
<option value="newer"
|
install init_datepicker"
|
||||||
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
id="filter" class="mt-3">
|
||||||
</select>
|
{% crispy filter.form %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Filter transactions form#}
|
|
||||||
<div class="collapse" id="collapse-filter">
|
|
||||||
<div class="card card-body">
|
|
||||||
<form _="on change or submit or search trigger updated on window end
|
|
||||||
install init_tom_select
|
|
||||||
install init_datepicker"
|
|
||||||
id="filter">
|
|
||||||
{% crispy filter.form %}
|
|
||||||
</form>
|
|
||||||
<button class="btn btn-outline-danger btn-sm"
|
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="transactions"
|
<div id="transactions"
|
||||||
class="show-loading"
|
class="show-loading"
|
||||||
hx-get="{% url 'transactions_all_list' %}"
|
hx-get="{% url 'transactions_all_list' %}"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
- ./frontend/:/usr/src/frontend:z
|
- ./frontend/:/usr/src/frontend:z
|
||||||
- wygiwyh_temp:/usr/src/app/temp/
|
- wygiwyh_temp:/usr/src/app/temp/
|
||||||
ports:
|
ports:
|
||||||
- "${OUTBOUND_PORT}:8000"
|
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
container_name: ${SERVER_NAME}
|
container_name: ${SERVER_NAME}
|
||||||
command: /start-single
|
command: /start-single
|
||||||
ports:
|
ports:
|
||||||
- "${OUTBOUND_PORT}:8000"
|
- "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ set -o errexit
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
set -o nounset
|
set -o nounset
|
||||||
|
|
||||||
|
# Set INTERNAL_PORT with default value of 8000
|
||||||
|
INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||||
|
|
||||||
rm -f /tmp/migrations_complete
|
rm -f /tmp/migrations_complete
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
@@ -13,4 +16,4 @@ touch /tmp/migrations_complete
|
|||||||
|
|
||||||
python manage.py setup_users
|
python manage.py setup_users
|
||||||
|
|
||||||
exec python manage.py runserver 0.0.0.0:8000
|
exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ set -o errexit
|
|||||||
set -o pipefail
|
set -o pipefail
|
||||||
set -o nounset
|
set -o nounset
|
||||||
|
|
||||||
|
# Set INTERNAL_PORT with default value of 8000
|
||||||
|
INTERNAL_PORT=${INTERNAL_PORT:-8000}
|
||||||
|
|
||||||
# Remove flag file if it exists from previous run
|
# Remove flag file if it exists from previous run
|
||||||
rm -f /tmp/migrations_complete
|
rm -f /tmp/migrations_complete
|
||||||
|
|
||||||
@@ -15,4 +18,4 @@ touch /tmp/migrations_complete
|
|||||||
|
|
||||||
python manage.py setup_users
|
python manage.py setup_users
|
||||||
|
|
||||||
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:8000 --timeout 600
|
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600
|
||||||
|
|||||||
@@ -49,3 +49,7 @@ $theme-colors: map.merge(
|
|||||||
.offcanvas-size-sm {
|
.offcanvas-size-sm {
|
||||||
--#{$prefix}offcanvas-width: min(95vw, 250px) !important;
|
--#{$prefix}offcanvas-width: min(95vw, 250px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-toggle.dropdown-toggle-no-icon::after {
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,139 @@
|
|||||||
@custom-variant hover (&:hover);
|
@custom-variant hover (&:hover);
|
||||||
|
|
||||||
.sidebar-active {
|
.sidebar-active {
|
||||||
@apply tw:bg-gray-700 tw:text-white;
|
@apply tw:bg-gray-700 tw:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item:not(.sidebar-active) {
|
.sidebar-item:not(.sidebar-active) {
|
||||||
@apply tw:text-gray-300 tw:hover:text-white;
|
@apply tw:text-gray-300 tw:hover:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.sidebar {
|
||||||
|
@apply tw:z-1020 tw:fixed tw:top-0 tw:start-0 tw:h-full tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating {
|
||||||
|
/* Establishes the hover group and sets the collapsed/hover widths for the container */
|
||||||
|
@apply tw:lg:w-16 tw:lg:hover:w-112;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating #sidebar {
|
||||||
|
/* Sets the collapsed/hover widths for the inner navigation element */
|
||||||
|
@apply tw:lg:w-16 tw:lg:group-hover:w-104 tw:transition-all tw:duration-100 tw:overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating + main {
|
||||||
|
/* Adjusts the main content margin to account for the collapsed sidebar */
|
||||||
|
@apply tw:lg:ml-16 tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-item span {
|
||||||
|
/* Hides the text labels and reveals them only on hover */
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-invisible {
|
||||||
|
/* Hides the text labels and reveals them only on hover */
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-menu-header {
|
||||||
|
/* Hides the menu headers and reveals them only on hover */
|
||||||
|
@apply tw:lg:hidden tw:lg:group-hover:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating #sidebar-toggle-btn .fa-thumbtack-slash {
|
||||||
|
/* Hides the 'pin' icon in the floating state */
|
||||||
|
@apply tw:hidden!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating #sidebar-toggle-btn .fa-thumbtack {
|
||||||
|
/* Shows the 'expand' icon in the floating state */
|
||||||
|
@apply tw:inline-block!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-title span {
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-submenu-header {
|
||||||
|
@apply tw:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-submenu-header {
|
||||||
|
@apply tw:lg:hidden tw:lg:group-hover:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-submenu-header h5 {
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-submenu-header button {
|
||||||
|
@apply tw:lg:hidden tw:lg:group-hover:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .list-unstyled {
|
||||||
|
@apply tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-item {
|
||||||
|
@apply tw:text-wrap tw:lg:text-nowrap ;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- STATE 2: Fixed (Permanently Expanded) --- */
|
||||||
|
.sidebar-fixed {
|
||||||
|
/* Sets the fixed, expanded width for the container */
|
||||||
|
@apply tw:lg:w-[17%] tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed #sidebar {
|
||||||
|
/* Sets the fixed, expanded width for the inner navigation */
|
||||||
|
@apply tw:lg:w-[17%] tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed + main {
|
||||||
|
/* Adjusts the main content margin to account for the expanded sidebar */
|
||||||
|
@apply tw:lg:ml-[17%] tw:transition-all tw:duration-100;
|
||||||
|
|
||||||
|
/* Using 16vw to account for padding/margins */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-item {
|
||||||
|
@apply tw:text-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-item span {
|
||||||
|
/* Ensures text labels are always visible */
|
||||||
|
@apply tw:lg:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-menu-header {
|
||||||
|
/* Ensures menu headers are always visible */
|
||||||
|
@apply tw:lg:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed #sidebar-toggle-btn .fa-thumbtack-slash {
|
||||||
|
/* Shows the 'pin' icon in the fixed state */
|
||||||
|
@apply tw:inline-block!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed #sidebar-toggle-btn .fa-thumbtack {
|
||||||
|
/* Hides the 'expand' icon in the fixed state */
|
||||||
|
@apply tw:hidden!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-title span {
|
||||||
|
@apply tw:lg:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-submenu-header {
|
||||||
|
/* Ensures menu headers are always visible */
|
||||||
|
@apply tw:lg:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .list-unstyled {
|
||||||
|
@apply tw:overflow-y-auto tw:overflow-x-hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user