mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e100d1b0 | ||
|
|
e49b38a442 | ||
|
|
1f2902eea9 | ||
|
|
7d60db8716 | ||
|
|
873b0baed7 | ||
|
|
2313c97761 | ||
|
|
9cd7337153 | ||
|
|
d3b354e2b8 | ||
|
|
e137666e99 | ||
|
|
4291a5b97d | ||
|
|
c8d316857f | ||
|
|
3395a96949 | ||
|
|
8ab9624619 | ||
|
|
f9056c3a45 | ||
|
|
a9df684ee2 | ||
|
|
e4d07c94d4 | ||
|
|
5d5d172b3b | ||
|
|
99f746b6be | ||
|
|
a461a33dc2 | ||
|
|
1213ffebeb | ||
|
|
c5a352cf4d | ||
|
|
cfcca54aa6 | ||
|
|
234f8cd669 | ||
|
|
43184140f0 | ||
|
|
acc325c150 | ||
|
|
46eb471a34 | ||
|
|
6dc14c73d6 | ||
|
|
f942924e7c | ||
|
|
aa6019e0a9 | ||
|
|
9dfbd346bc | ||
|
|
73b1d36dfd | ||
|
|
3662fb030a | ||
|
|
a423ee1032 | ||
|
|
72eb59d24f | ||
|
|
1a0247e028 | ||
|
|
281a0fccda | ||
|
|
59ce50299a | ||
|
|
be89509beb | ||
|
|
80cded234d | ||
|
|
030bb63586 | ||
|
|
66e8fc5884 | ||
|
|
363047337d | ||
|
|
c7e32d1576 | ||
|
|
157e59a1d1 | ||
|
|
d9c505ac79 | ||
|
|
7274a13f3c | ||
|
|
5d64665ddd | ||
|
|
e0d92d15c8 | ||
|
|
48dd658627 | ||
|
|
80dbbd02f0 | ||
|
|
4b7ca61c29 | ||
|
|
b2f04ae1f9 | ||
|
|
f34d4b5e28 | ||
|
|
d2ebfbd615 | ||
|
|
812abbe488 | ||
|
|
9602a4affc | ||
|
|
bf548c0747 | ||
|
|
55ad2be08b | ||
|
|
2cd58c2464 | ||
|
|
4675ba9d56 | ||
|
|
a25c992d5c | ||
|
|
2eadfe99a5 | ||
|
|
11086a726f | ||
|
|
cd99b40b0a | ||
|
|
63aa51dc0d | ||
|
|
4708c5bc7e | ||
|
|
5a8462c050 | ||
|
|
6cac02e01f | ||
|
|
8d12ceeebb | ||
|
|
4681d3ca1d | ||
|
|
60ded03ea9 | ||
|
|
b20d137dc3 | ||
|
|
29ca6eed6c |
@@ -31,3 +31,10 @@ ENABLE_SOFT_DELETE=false
|
||||
KEEP_DELETED_TRANSACTIONS_FOR=365
|
||||
|
||||
TASK_WORKERS=1 # This only work if you're using the single container option. Increase to have more open queues via procrastinate, you probably don't need to increase this.
|
||||
|
||||
# OIDC Configuration. Uncomment the lines below if you want to add OIDC login to your instance
|
||||
#OIDC_CLIENT_NAME=""
|
||||
#OIDC_CLIENT_ID=""
|
||||
#OIDC_CLIENT_SECRET=""
|
||||
#OIDC_SERVER_URL=""
|
||||
#OIDC_ALLOW_SIGNUP=true
|
||||
|
||||
25
README.md
25
README.md
@@ -144,6 +144,31 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
|
||||
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
WYGIWYH supports login via OpenID Connect (OIDC) through `django-allauth`. This allows users to authenticate using an external OIDC provider.
|
||||
|
||||
> [!NOTE]
|
||||
> Currently only OpenID Connect is supported as a provider, open an issue if you need something else.
|
||||
|
||||
To configure OIDC, you need to set the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `OIDC_CLIENT_NAME` | The name of the provider. will be displayed in the login page. Defaults to `OpenID Connect` |
|
||||
| `OIDC_CLIENT_ID` | The Client ID provided by your OIDC provider. |
|
||||
| `OIDC_CLIENT_SECRET` | The Client Secret provided by your OIDC provider. |
|
||||
| `OIDC_SERVER_URL` | The base URL of your OIDC provider's discovery document or authorization server (e.g., `https://your-provider.com/auth/realms/your-realm`). `django-allauth` will use this to discover the necessary endpoints (authorization, token, userinfo, etc.). |
|
||||
| `OIDC_ALLOW_SIGNUP` | Allow the automatic creation of inexistent accounts on a successfull authentication. Defaults to `true`. |
|
||||
|
||||
**Callback URL (Redirect URI):**
|
||||
|
||||
When configuring your OIDC provider, you will need to provide a callback URL (also known as a Redirect URI). For WYGIWYH, the default callback URL is:
|
||||
|
||||
`https://your.wygiwyh.domain/daa/accounts/oidc/<OIDC_CLIENT_NAME>/login/callback/`
|
||||
|
||||
Replace `https://your.wygiwyh.domain` with the actual URL where your WYGIWYH instance is accessible. And `<OIDC_CLIENT_NAME>` with the slugfied value set in OIDC_CLIENT_NAME or the default `openid-connect` if you haven't set this variable.
|
||||
|
||||
# How it works
|
||||
|
||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
SITE_TITLE = "WYGIWYH"
|
||||
TITLE_SEPARATOR = "::"
|
||||
@@ -42,6 +43,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.sites",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
"webpack_boilerplate",
|
||||
@@ -61,7 +63,6 @@ INSTALLED_APPS = [
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
"apps.currencies.apps.CurrenciesConfig",
|
||||
"apps.accounts.apps.AccountsConfig",
|
||||
"apps.common.apps.CommonConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
"apps.export_app.apps.ExportConfig",
|
||||
@@ -74,8 +75,15 @@ INSTALLED_APPS = [
|
||||
"apps.calendar_view.apps.CalendarViewConfig",
|
||||
"apps.dca.apps.DcaConfig",
|
||||
"pwa",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.openid_connect",
|
||||
"apps.common.apps.CommonConfig",
|
||||
]
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
|
||||
@@ -91,6 +99,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "WYGIWYH.urls"
|
||||
@@ -307,6 +316,42 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGIN_URL = "/login/"
|
||||
LOGOUT_REDIRECT_URL = "/login/"
|
||||
|
||||
# Allauth settings
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend", # Keep default
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {"openid_connect": {"APPS": []}}
|
||||
|
||||
if (
|
||||
os.getenv("OIDC_CLIENT_ID")
|
||||
and os.getenv("OIDC_CLIENT_SECRET")
|
||||
and os.getenv("OIDC_SERVER_URL")
|
||||
):
|
||||
SOCIALACCOUNT_PROVIDERS["openid_connect"]["APPS"].append(
|
||||
{
|
||||
"provider_id": slugify(os.getenv("OIDC_CLIENT_NAME", "OpenID Connect")),
|
||||
"name": os.getenv("OIDC_CLIENT_NAME", "OpenID Connect"),
|
||||
"client_id": os.getenv("OIDC_CLIENT_ID"),
|
||||
"secret": os.getenv("OIDC_CLIENT_SECRET"),
|
||||
"settings": {
|
||||
"server_url": os.getenv("OIDC_SERVER_URL"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ACCOUNT_LOGIN_METHODS = {"email"}
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||
SOCIALACCOUNT_ONLY = True
|
||||
SOCIALACCOUNT_AUTO_SIGNUP = os.getenv("OIDC_ALLOW_SIGNUP", "true").lower() == "true"
|
||||
ACCOUNT_ADAPTER = "allauth.account.adapter.DefaultAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "allauth.socialaccount.adapter.DefaultSocialAccountAdapter"
|
||||
|
||||
# CRISPY FORMS
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
||||
|
||||
@@ -21,6 +21,8 @@ from drf_spectacular.views import (
|
||||
SpectacularAPIView,
|
||||
SpectacularSwaggerView,
|
||||
)
|
||||
from allauth.socialaccount.providers.openid_connect.views import login, callback
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
@@ -36,6 +38,13 @@ urlpatterns = [
|
||||
SpectacularSwaggerView.as_view(url_name="schema"),
|
||||
name="swagger-ui",
|
||||
),
|
||||
path("auth/", include("allauth.urls")), # allauth urls
|
||||
# path("auth/oidc/<str:provider_id>/login/", login, name="openid_connect_login"),
|
||||
# path(
|
||||
# "auth/oidc/<str:provider_id>/login/callback/",
|
||||
# callback,
|
||||
# name="openid_connect_callback",
|
||||
# ),
|
||||
path("", include("apps.transactions.urls")),
|
||||
path("", include("apps.common.urls")),
|
||||
path("", include("apps.users.urls")),
|
||||
|
||||
@@ -51,7 +51,7 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"account-groups/<int:pk>/share/",
|
||||
views.account_share,
|
||||
views.account_group_share,
|
||||
name="account_group_share_settings",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -145,7 +145,7 @@ def account_group_take_ownership(request, pk):
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def account_share(request, pk):
|
||||
def account_group_share(request, pk):
|
||||
obj = get_object_or_404(AccountGroup, id=pk)
|
||||
|
||||
if obj.owner and obj.owner != request.user:
|
||||
|
||||
@@ -41,7 +41,10 @@ class TransactionCategoryField(serializers.Field):
|
||||
def get_schema():
|
||||
return {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "description": "TransactionTag ID or name"},
|
||||
"items": {
|
||||
"type": "string",
|
||||
"description": "TransactionCategory ID or name",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import Q
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
@@ -22,6 +23,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
currency = CurrencySerializer(read_only=True)
|
||||
currency_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Currency.objects.all(), source="currency", write_only=True
|
||||
@@ -50,6 +52,13 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"is_asset",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get("request")
|
||||
if request and request.user.is_authenticated:
|
||||
# Reload the queryset to get an updated version with the requesting user
|
||||
self.fields["group_id"].queryset = AccountGroup.objects.all()
|
||||
|
||||
def create(self, validated_data):
|
||||
return Account.objects.create(**validated_data)
|
||||
|
||||
|
||||
@@ -4,3 +4,17 @@ from django.apps import AppConfig
|
||||
class CommonConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.common"
|
||||
|
||||
def ready(self):
|
||||
from django.contrib import admin
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import (
|
||||
SocialAccount,
|
||||
SocialApp,
|
||||
SocialToken,
|
||||
)
|
||||
|
||||
admin.site.unregister(Site)
|
||||
admin.site.unregister(SocialAccount)
|
||||
admin.site.unregister(SocialApp)
|
||||
admin.site.unregister(SocialToken)
|
||||
|
||||
@@ -2,6 +2,7 @@ from crispy_forms.bootstrap import FormActions
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.exceptions import ValidationError
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Submit, Div, HTML
|
||||
|
||||
@@ -81,6 +82,23 @@ class SharedObjectForm(forms.Form):
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
owner = cleaned_data.get("owner")
|
||||
shared_with_users = cleaned_data.get("shared_with_users", [])
|
||||
|
||||
# Raise validation error if owner is in shared_with_users
|
||||
if owner and owner in shared_with_users:
|
||||
self.add_error(
|
||||
"shared_with_users",
|
||||
ValidationError(
|
||||
_("You cannot share this item with its owner."),
|
||||
code="invalid_share",
|
||||
),
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self):
|
||||
instance = self.instance
|
||||
|
||||
|
||||
@@ -65,6 +65,18 @@ class SharedObject(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class OwnedObjectManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
"""Return only objects the user can access"""
|
||||
user = get_current_user()
|
||||
base_qs = super().get_queryset()
|
||||
|
||||
if user and user.is_authenticated:
|
||||
return base_qs.filter(Q(owner=user) | Q(owner=None)).distinct()
|
||||
|
||||
return base_qs
|
||||
|
||||
|
||||
class OwnedObject(models.Model):
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
|
||||
@@ -10,7 +10,7 @@ from apps.currencies.utils.convert import convert
|
||||
|
||||
|
||||
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
# Get metrics for each category and currency in a single query
|
||||
# First get the category totals as before
|
||||
category_currency_metrics = (
|
||||
transactions_queryset.values(
|
||||
"category",
|
||||
@@ -74,9 +74,65 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
.order_by("category__name")
|
||||
)
|
||||
|
||||
# Get tag totals within each category with currency details
|
||||
tag_metrics = transactions_queryset.values(
|
||||
"category",
|
||||
"tags",
|
||||
"tags__name",
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
"account__currency__name",
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
).annotate(
|
||||
expense_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
expense_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.EXPENSE, is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
)
|
||||
|
||||
# Process the results to structure by category
|
||||
result = {}
|
||||
|
||||
# Process category totals first
|
||||
for metric in category_currency_metrics:
|
||||
# Skip empty categories if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
@@ -101,7 +157,11 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
currency_id = metric["account__currency"]
|
||||
|
||||
if category_id not in result:
|
||||
result[category_id] = {"name": metric["category__name"], "currencies": {}}
|
||||
result[category_id] = {
|
||||
"name": metric["category__name"],
|
||||
"currencies": {},
|
||||
"tags": {}, # Add tags container
|
||||
}
|
||||
|
||||
# Add currency data
|
||||
currency_data = {
|
||||
@@ -162,4 +222,101 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
|
||||
result[category_id]["currencies"][currency_id] = currency_data
|
||||
|
||||
# Process tag totals and add them to the result, including untagged
|
||||
for tag_metric in tag_metrics:
|
||||
category_id = tag_metric["category"]
|
||||
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
||||
|
||||
if category_id in result:
|
||||
# Initialize the tag container if not exists
|
||||
if "tags" not in result[category_id]:
|
||||
result[category_id]["tags"] = {}
|
||||
|
||||
# Determine if this is a tagged or untagged transaction
|
||||
tag_key = tag_id if tag_id is not None else "untagged"
|
||||
tag_name = tag_metric["tags__name"] if tag_id is not None else None
|
||||
|
||||
if tag_key not in result[category_id]["tags"]:
|
||||
result[category_id]["tags"][tag_key] = {
|
||||
"name": tag_name,
|
||||
"currencies": {},
|
||||
}
|
||||
|
||||
currency_id = tag_metric["account__currency"]
|
||||
|
||||
# Calculate tag totals
|
||||
tag_total_current = (
|
||||
tag_metric["income_current"] - tag_metric["expense_current"]
|
||||
)
|
||||
tag_total_projected = (
|
||||
tag_metric["income_projected"] - tag_metric["expense_projected"]
|
||||
)
|
||||
tag_total_income = (
|
||||
tag_metric["income_current"] + tag_metric["income_projected"]
|
||||
)
|
||||
tag_total_expense = (
|
||||
tag_metric["expense_current"] + tag_metric["expense_projected"]
|
||||
)
|
||||
tag_total_final = tag_total_current + tag_total_projected
|
||||
|
||||
tag_currency_data = {
|
||||
"currency": {
|
||||
"code": tag_metric["account__currency__code"],
|
||||
"name": tag_metric["account__currency__name"],
|
||||
"decimal_places": tag_metric["account__currency__decimal_places"],
|
||||
"prefix": tag_metric["account__currency__prefix"],
|
||||
"suffix": tag_metric["account__currency__suffix"],
|
||||
},
|
||||
"expense_current": tag_metric["expense_current"],
|
||||
"expense_projected": tag_metric["expense_projected"],
|
||||
"total_expense": tag_total_expense,
|
||||
"income_current": tag_metric["income_current"],
|
||||
"income_projected": tag_metric["income_projected"],
|
||||
"total_income": tag_total_income,
|
||||
"total_current": tag_total_current,
|
||||
"total_projected": tag_total_projected,
|
||||
"total_final": tag_total_final,
|
||||
}
|
||||
|
||||
# Add exchange currency support for tags
|
||||
if tag_metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=tag_metric["account__currency__exchange_currency"]
|
||||
)
|
||||
|
||||
exchanged = {}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_income",
|
||||
"total_expense",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=tag_currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
if exchanged:
|
||||
tag_currency_data["exchanged"] = exchanged
|
||||
|
||||
result[category_id]["tags"][tag_key]["currencies"][
|
||||
currency_id
|
||||
] = tag_currency_data
|
||||
|
||||
return result
|
||||
|
||||
@@ -168,6 +168,24 @@ def category_sum_by_currency(request):
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_overview(request):
|
||||
if "view_type" in request.GET:
|
||||
view_type = request.GET["view_type"]
|
||||
request.session["insights_category_explorer_view_type"] = view_type
|
||||
else:
|
||||
view_type = request.session.get("insights_category_explorer_view_type", "table")
|
||||
|
||||
if "show_tags" in request.GET:
|
||||
show_tags = request.GET["show_tags"] == "on"
|
||||
request.session["insights_category_explorer_show_tags"] = show_tags
|
||||
else:
|
||||
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
||||
|
||||
if "showing" in request.GET:
|
||||
showing = request.GET["showing"]
|
||||
request.session["insights_category_explorer_showing"] = showing
|
||||
else:
|
||||
showing = request.session.get("insights_category_explorer_showing", "final")
|
||||
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
@@ -178,7 +196,12 @@ def category_overview(request):
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_overview/index.html",
|
||||
{"total_table": total_table},
|
||||
{
|
||||
"total_table": total_table,
|
||||
"view_type": view_type,
|
||||
"show_tags": show_tags,
|
||||
"showing": showing,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def net_worth_current(request):
|
||||
)
|
||||
|
||||
currency_net_worth = calculate_currency_totals(
|
||||
transactions_queryset=transactions_currency_queryset
|
||||
transactions_queryset=transactions_currency_queryset, deep_search=True
|
||||
)
|
||||
account_net_worth = calculate_account_totals(
|
||||
transactions_queryset=transactions_account_queryset
|
||||
@@ -137,7 +137,7 @@ def net_worth_projected(request):
|
||||
)
|
||||
|
||||
currency_net_worth = calculate_currency_totals(
|
||||
transactions_queryset=transactions_currency_queryset
|
||||
transactions_queryset=transactions_currency_queryset, deep_search=True
|
||||
)
|
||||
account_net_worth = calculate_account_totals(
|
||||
transactions_queryset=transactions_account_queryset
|
||||
|
||||
@@ -6,6 +6,8 @@ from crispy_forms.layout import (
|
||||
Row,
|
||||
Column,
|
||||
Field,
|
||||
Div,
|
||||
HTML,
|
||||
)
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
@@ -28,8 +30,8 @@ from apps.transactions.models import (
|
||||
InstallmentPlan,
|
||||
RecurringTransaction,
|
||||
TransactionEntity,
|
||||
QuickTransaction,
|
||||
)
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
@@ -206,10 +208,21 @@ class TransactionForm(forms.ModelForm):
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
Div(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||
),
|
||||
NoClassSubmit(
|
||||
"submit_and_similar",
|
||||
_("Save and add similar"),
|
||||
css_class="btn btn-outline-primary",
|
||||
),
|
||||
NoClassSubmit(
|
||||
"submit_and_another",
|
||||
_("Save and add another"),
|
||||
css_class="btn btn-outline-primary",
|
||||
),
|
||||
css_class="d-grid gap-2",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -235,6 +248,140 @@ class TransactionForm(forms.ModelForm):
|
||||
return instance
|
||||
|
||||
|
||||
class QuickTransactionForm(forms.ModelForm):
|
||||
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"),
|
||||
)
|
||||
account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
label=_("Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = QuickTransaction
|
||||
fields = [
|
||||
"name",
|
||||
"account",
|
||||
"type",
|
||||
"is_paid",
|
||||
"amount",
|
||||
"description",
|
||||
"notes",
|
||||
"category",
|
||||
"tags",
|
||||
"entities",
|
||||
]
|
||||
widgets = {
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
"account": TomSelect(clear_button=False, group_by="group"),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# if editing a transaction display non-archived items and it's own item even if it's archived
|
||||
if self.instance.id:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
Q(is_archived=False) | Q(transactions=self.instance.id),
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
)
|
||||
|
||||
self.fields["tags"].queryset = TransactionTag.objects.filter(
|
||||
Q(active=True) | Q(transaction=self.instance.id)
|
||||
)
|
||||
|
||||
self.fields["entities"].queryset = TransactionEntity.objects.filter(
|
||||
Q(active=True) | Q(transactions=self.instance.id)
|
||||
)
|
||||
else:
|
||||
self.fields["account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
)
|
||||
|
||||
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||
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(
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Field("is_paid", template="transactions/widgets/paid_toggle_button.html"),
|
||||
"name",
|
||||
HTML("<hr />"),
|
||||
Row(
|
||||
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||
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",
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=decimal_places
|
||||
)
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
Div(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary"
|
||||
),
|
||||
css_class="d-grid gap-2",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BulkEditTransactionForm(TransactionForm):
|
||||
is_paid = forms.NullBooleanField(required=False)
|
||||
|
||||
|
||||
45
app/apps/transactions/migrations/0043_quicktransaction.py
Normal file
45
app/apps/transactions/migrations/0043_quicktransaction.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-20 03:57
|
||||
|
||||
import apps.transactions.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0014_alter_account_options_alter_accountgroup_options'),
|
||||
('transactions', '0042_alter_transactioncategory_options_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QuickTransaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='Name')),
|
||||
('type', models.CharField(choices=[('IN', 'Income'), ('EX', 'Expense')], default='EX', max_length=2, verbose_name='Type')),
|
||||
('is_paid', models.BooleanField(default=True, verbose_name='Paid')),
|
||||
('amount', models.DecimalField(decimal_places=30, max_digits=42, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount')),
|
||||
('description', models.CharField(blank=True, max_length=500, verbose_name='Description')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Notes')),
|
||||
('internal_note', models.TextField(blank=True, verbose_name='Internal Note')),
|
||||
('internal_id', models.TextField(blank=True, null=True, unique=True, verbose_name='Internal ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quick_transactions', to='accounts.account', verbose_name='Account')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='transactions.transactioncategory', verbose_name='Category')),
|
||||
('entities', models.ManyToManyField(blank=True, related_name='quick_transactions', to='transactions.transactionentity', verbose_name='Entities')),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)),
|
||||
('tags', models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Quick Transaction',
|
||||
'verbose_name_plural': 'Quick Transactions',
|
||||
'db_table': 'quick_transactions',
|
||||
'default_manager_name': 'objects',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-20 04:02
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0043_quicktransaction'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='quicktransaction',
|
||||
unique_together={('name', 'owner')},
|
||||
),
|
||||
]
|
||||
@@ -16,7 +16,12 @@ from apps.common.templatetags.decimal import localize_number, drop_trailing_zero
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from apps.common.middleware.thread_local import get_current_user
|
||||
from apps.common.models import SharedObject, SharedObjectManager, OwnedObject
|
||||
from apps.common.models import (
|
||||
SharedObject,
|
||||
SharedObjectManager,
|
||||
OwnedObject,
|
||||
OwnedObjectManager,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
@@ -118,13 +123,20 @@ class SoftDeleteManager(models.Manager):
|
||||
qs = SoftDeleteQuerySet(self.model, using=self._db)
|
||||
user = get_current_user()
|
||||
if user and not user.is_anonymous:
|
||||
return qs.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
deleted=False,
|
||||
).distinct()
|
||||
account_ids = (
|
||||
qs.filter(
|
||||
Q(account__visibility="public")
|
||||
| Q(account__owner=user)
|
||||
| Q(account__shared_with=user)
|
||||
| Q(account__visibility="private", account__owner=None),
|
||||
deleted=False,
|
||||
)
|
||||
.values_list("account__id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return qs.filter(account_id__in=account_ids, deleted=False)
|
||||
|
||||
else:
|
||||
return qs.filter(
|
||||
deleted=False,
|
||||
@@ -879,3 +891,86 @@ class RecurringTransaction(models.Model):
|
||||
"""
|
||||
today = timezone.localdate(timezone.now())
|
||||
self.transactions.filter(is_paid=False, date__gt=today).delete()
|
||||
|
||||
|
||||
class QuickTransaction(OwnedObject):
|
||||
class Type(models.TextChoices):
|
||||
INCOME = "IN", _("Income")
|
||||
EXPENSE = "EX", _("Expense")
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
null=False,
|
||||
blank=False,
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account",
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("Account"),
|
||||
related_name="quick_transactions",
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=2,
|
||||
choices=Type,
|
||||
default=Type.EXPENSE,
|
||||
verbose_name=_("Type"),
|
||||
)
|
||||
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
verbose_name=_("Amount"),
|
||||
validators=[validate_non_negative, validate_decimal_places],
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
max_length=500, verbose_name=_("Description"), blank=True
|
||||
)
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
category = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
tags = models.ManyToManyField(
|
||||
TransactionTag,
|
||||
verbose_name=_("Tags"),
|
||||
blank=True,
|
||||
)
|
||||
entities = models.ManyToManyField(
|
||||
TransactionEntity,
|
||||
verbose_name=_("Entities"),
|
||||
blank=True,
|
||||
related_name="quick_transactions",
|
||||
)
|
||||
|
||||
internal_note = models.TextField(blank=True, verbose_name=_("Internal Note"))
|
||||
internal_id = models.TextField(
|
||||
blank=True, null=True, unique=True, verbose_name=_("Internal ID")
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = OwnedObjectManager()
|
||||
all_objects = models.Manager() # Unfiltered manager
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Quick Transaction")
|
||||
verbose_name_plural = _("Quick Transactions")
|
||||
unique_together = ("name", "owner")
|
||||
db_table = "quick_transactions"
|
||||
default_manager_name = "objects"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.amount = truncate_decimal(
|
||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||
)
|
||||
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -307,4 +307,39 @@ urlpatterns = [
|
||||
views.recurring_transaction_finish,
|
||||
name="recurring_transaction_finish",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/",
|
||||
views.quick_transactions_index,
|
||||
name="quick_transactions_index",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/list/",
|
||||
views.quick_transactions_list,
|
||||
name="quick_transactions_list",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/add/",
|
||||
views.quick_transaction_add,
|
||||
name="quick_transaction_add",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/<int:quick_transaction_id>/edit/",
|
||||
views.quick_transaction_edit,
|
||||
name="quick_transaction_edit",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/<int:quick_transaction_id>/delete/",
|
||||
views.quick_transaction_delete,
|
||||
name="quick_transaction_delete",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/create-menu/",
|
||||
views.quick_transactions_create_menu,
|
||||
name="quick_transactions_create_menu",
|
||||
),
|
||||
path(
|
||||
"quick-transactions/<int:quick_transaction_id>/create/",
|
||||
views.quick_transaction_add_as_transaction,
|
||||
name="quick_transaction_add_as_transaction",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,9 +9,11 @@ from apps.currencies.utils.convert import convert
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
def calculate_currency_totals(
|
||||
transactions_queryset, ignore_empty=False, deep_search=False
|
||||
):
|
||||
# Prepare the aggregation expressions
|
||||
currency_totals = (
|
||||
currency_totals_from_transactions = (
|
||||
transactions_queryset.values(
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
@@ -19,7 +21,14 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
"account__currency__exchange_currency", # ID of the exchange currency for the account's currency
|
||||
# Fields for the exchange currency itself (if account.currency.exchange_currency is set)
|
||||
# These might be null if not set, so handle appropriately.
|
||||
"account__currency__exchange_currency__code",
|
||||
"account__currency__exchange_currency__name",
|
||||
"account__currency__exchange_currency__decimal_places",
|
||||
"account__currency__exchange_currency__prefix",
|
||||
"account__currency__exchange_currency__suffix",
|
||||
)
|
||||
.annotate(
|
||||
expense_current=Coalesce(
|
||||
@@ -72,36 +81,55 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
.order_by()
|
||||
)
|
||||
|
||||
# First pass: Process basic totals and store all currency data
|
||||
result = {}
|
||||
currencies_using_exchange = (
|
||||
{}
|
||||
) # Track which currencies use which exchange currencies
|
||||
# currencies_using_exchange maps:
|
||||
# exchange_currency_id -> list of [
|
||||
# { "currency_id": original_currency_id, (the currency that was exchanged FROM)
|
||||
# "exchanged": { field: amount_in_exchange_currency, ... } (the values of original_currency_id converted TO exchange_currency_id)
|
||||
# }
|
||||
# ]
|
||||
currencies_using_exchange = {}
|
||||
|
||||
for total in currency_totals:
|
||||
# Skip empty currencies if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
total[field] == Decimal("0")
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
]
|
||||
# --- First Pass: Process transactions from the queryset ---
|
||||
for total in currency_totals_from_transactions:
|
||||
if (
|
||||
ignore_empty
|
||||
and not deep_search
|
||||
and all(
|
||||
total[field] == Decimal("0")
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
]
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate derived totals
|
||||
currency_id = total["account__currency"]
|
||||
try:
|
||||
from_currency_obj = Currency.objects.get(id=currency_id)
|
||||
except Currency.DoesNotExist:
|
||||
# This should ideally not happen if database is consistent
|
||||
continue
|
||||
|
||||
exchange_currency_for_this_total_id = total[
|
||||
"account__currency__exchange_currency"
|
||||
]
|
||||
exchange_currency_obj_for_this_total = None
|
||||
if exchange_currency_for_this_total_id:
|
||||
try:
|
||||
# Use pre-fetched values if available, otherwise query
|
||||
exchange_currency_obj_for_this_total = Currency.objects.get(
|
||||
id=exchange_currency_for_this_total_id
|
||||
)
|
||||
except Currency.DoesNotExist:
|
||||
pass # Exchange currency might not exist or be set
|
||||
|
||||
total_current = total["income_current"] - total["expense_current"]
|
||||
total_projected = total["income_projected"] - total["expense_projected"]
|
||||
total_final = total_current + total_projected
|
||||
currency_id = total["account__currency"]
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = (
|
||||
Currency.objects.get(id=total["account__currency__exchange_currency"])
|
||||
if total["account__currency__exchange_currency"]
|
||||
else None
|
||||
)
|
||||
|
||||
currency_data = {
|
||||
"currency": {
|
||||
@@ -120,9 +148,16 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_final": total_final,
|
||||
}
|
||||
|
||||
# Add exchanged values if exchange_currency exists
|
||||
if exchange_currency:
|
||||
exchanged = {}
|
||||
if exchange_currency_obj_for_this_total:
|
||||
exchanged_details = {
|
||||
"currency": {
|
||||
"code": exchange_currency_obj_for_this_total.code,
|
||||
"name": exchange_currency_obj_for_this_total.name,
|
||||
"decimal_places": exchange_currency_obj_for_this_total.decimal_places,
|
||||
"prefix": exchange_currency_obj_for_this_total.prefix,
|
||||
"suffix": exchange_currency_obj_for_this_total.suffix,
|
||||
}
|
||||
}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -132,50 +167,142 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
amount_to_convert = currency_data[field]
|
||||
converted_val, _, _, _ = convert(
|
||||
amount=amount_to_convert,
|
||||
from_currency=from_currency_obj,
|
||||
to_currency=exchange_currency_obj_for_this_total,
|
||||
)
|
||||
exchanged_details[field] = (
|
||||
converted_val if converted_val is not None else Decimal("0")
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
|
||||
if exchanged:
|
||||
currency_data["exchanged"] = exchanged
|
||||
# Track which currencies are using which exchange currencies
|
||||
if exchange_currency.id not in currencies_using_exchange:
|
||||
currencies_using_exchange[exchange_currency.id] = []
|
||||
currencies_using_exchange[exchange_currency.id].append(
|
||||
{"currency_id": currency_id, "exchanged": exchanged}
|
||||
)
|
||||
currency_data["exchanged"] = exchanged_details
|
||||
|
||||
if exchange_currency_obj_for_this_total.id not in currencies_using_exchange:
|
||||
currencies_using_exchange[exchange_currency_obj_for_this_total.id] = []
|
||||
currencies_using_exchange[exchange_currency_obj_for_this_total.id].append(
|
||||
{"currency_id": currency_id, "exchanged": exchanged_details}
|
||||
)
|
||||
|
||||
result[currency_id] = currency_data
|
||||
|
||||
# Second pass: Add consolidated totals for currencies that are used as exchange currencies
|
||||
for currency_id, currency_data in result.items():
|
||||
if currency_id in currencies_using_exchange:
|
||||
consolidated = {
|
||||
"currency": currency_data["currency"].copy(),
|
||||
"expense_current": currency_data["expense_current"],
|
||||
"expense_projected": currency_data["expense_projected"],
|
||||
"income_current": currency_data["income_current"],
|
||||
"income_projected": currency_data["income_projected"],
|
||||
"total_current": currency_data["total_current"],
|
||||
"total_projected": currency_data["total_projected"],
|
||||
"total_final": currency_data["total_final"],
|
||||
}
|
||||
# --- Deep Search: Add transaction-less currencies that are exchange targets ---
|
||||
if deep_search:
|
||||
# Iteratively add exchange targets that might not have had direct transactions
|
||||
# Start with known exchange targets from the first pass
|
||||
queue = list(currencies_using_exchange.keys())
|
||||
processed_for_deep_add = set(
|
||||
result.keys()
|
||||
) # Track currencies already in result or added by this deep search step
|
||||
|
||||
# Add exchanged values from all currencies using this as exchange currency
|
||||
for using_currency in currencies_using_exchange[currency_id]:
|
||||
exchanged = using_currency["exchanged"]
|
||||
while queue:
|
||||
target_id = queue.pop(0)
|
||||
if target_id in processed_for_deep_add:
|
||||
continue
|
||||
processed_for_deep_add.add(target_id)
|
||||
|
||||
if (
|
||||
target_id not in result
|
||||
): # If this exchange target had no direct transactions
|
||||
try:
|
||||
db_currency = Currency.objects.get(id=target_id)
|
||||
except Currency.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Initialize data for this transaction-less exchange target currency
|
||||
currency_data_for_db_currency = {
|
||||
"currency": {
|
||||
"code": db_currency.code,
|
||||
"name": db_currency.name,
|
||||
"decimal_places": db_currency.decimal_places,
|
||||
"prefix": db_currency.prefix,
|
||||
"suffix": db_currency.suffix,
|
||||
},
|
||||
"expense_current": Decimal("0"),
|
||||
"expense_projected": Decimal("0"),
|
||||
"income_current": Decimal("0"),
|
||||
"income_projected": Decimal("0"),
|
||||
"total_current": Decimal("0"),
|
||||
"total_projected": Decimal("0"),
|
||||
"total_final": Decimal("0"),
|
||||
}
|
||||
|
||||
# If this newly added transaction-less currency ALSO has an exchange_currency set for itself
|
||||
if db_currency.exchange_currency:
|
||||
exchanged_details_for_db_currency = {
|
||||
"currency": {
|
||||
"code": db_currency.exchange_currency.code,
|
||||
"name": db_currency.exchange_currency.name,
|
||||
"decimal_places": db_currency.exchange_currency.decimal_places,
|
||||
"prefix": db_currency.exchange_currency.prefix,
|
||||
"suffix": db_currency.exchange_currency.suffix,
|
||||
}
|
||||
}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
converted_val, _, _, _ = convert(
|
||||
Decimal("0"), db_currency, db_currency.exchange_currency
|
||||
)
|
||||
exchanged_details_for_db_currency[field] = (
|
||||
converted_val if converted_val is not None else Decimal("0")
|
||||
)
|
||||
|
||||
currency_data_for_db_currency["exchanged"] = (
|
||||
exchanged_details_for_db_currency
|
||||
)
|
||||
|
||||
# Ensure its own exchange_currency is registered in currencies_using_exchange
|
||||
# and add it to the queue if it hasn't been processed yet for deep add.
|
||||
target_id_for_this_db_curr = db_currency.exchange_currency.id
|
||||
if target_id_for_this_db_curr not in currencies_using_exchange:
|
||||
currencies_using_exchange[target_id_for_this_db_curr] = []
|
||||
|
||||
# Avoid adding duplicate entries
|
||||
already_present_in_cue = any(
|
||||
entry["currency_id"] == db_currency.id
|
||||
for entry in currencies_using_exchange[
|
||||
target_id_for_this_db_curr
|
||||
]
|
||||
)
|
||||
if not already_present_in_cue:
|
||||
currencies_using_exchange[target_id_for_this_db_curr].append(
|
||||
{
|
||||
"currency_id": db_currency.id,
|
||||
"exchanged": exchanged_details_for_db_currency,
|
||||
}
|
||||
)
|
||||
|
||||
if target_id_for_this_db_curr not in processed_for_deep_add:
|
||||
queue.append(target_id_for_this_db_curr)
|
||||
|
||||
result[db_currency.id] = currency_data_for_db_currency
|
||||
|
||||
# --- Second Pass: Calculate consolidated totals for all currencies in result ---
|
||||
for currency_id_consolidated, data_consolidated_currency in result.items():
|
||||
consolidated_data = {
|
||||
"currency": data_consolidated_currency["currency"].copy(),
|
||||
"expense_current": data_consolidated_currency["expense_current"],
|
||||
"expense_projected": data_consolidated_currency["expense_projected"],
|
||||
"income_current": data_consolidated_currency["income_current"],
|
||||
"income_projected": data_consolidated_currency["income_projected"],
|
||||
"total_current": data_consolidated_currency["total_current"],
|
||||
"total_projected": data_consolidated_currency["total_projected"],
|
||||
"total_final": data_consolidated_currency["total_final"],
|
||||
}
|
||||
|
||||
if currency_id_consolidated in currencies_using_exchange:
|
||||
for original_currency_info in currencies_using_exchange[
|
||||
currency_id_consolidated
|
||||
]:
|
||||
exchanged_values_from_original = original_currency_info["exchanged"]
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -185,10 +312,25 @@ def calculate_currency_totals(transactions_queryset, ignore_empty=False):
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
if field in exchanged:
|
||||
consolidated[field] += exchanged[field]
|
||||
if field in exchanged_values_from_original:
|
||||
consolidated_data[field] += exchanged_values_from_original[
|
||||
field
|
||||
]
|
||||
|
||||
result[currency_id]["consolidated"] = consolidated
|
||||
result[currency_id_consolidated]["consolidated"] = consolidated_data
|
||||
|
||||
# Sort currencies by their final_total or consolidated final_total, descending
|
||||
result = {
|
||||
k: v
|
||||
for k, v in sorted(
|
||||
result.items(),
|
||||
reverse=True,
|
||||
key=lambda item: max(
|
||||
item[1].get("total_final", Decimal("0")),
|
||||
item[1].get("consolidated", {}).get("total_final", Decimal("0")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ from .categories import *
|
||||
from .actions import *
|
||||
from .installment_plans import *
|
||||
from .recurring_transactions import *
|
||||
from .quick_transactions import *
|
||||
|
||||
152
app/apps/transactions/views/quick_transactions.py
Normal file
152
app/apps/transactions/views/quick_transactions.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.forms import model_to_dict
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import QuickTransactionForm
|
||||
from apps.transactions.models import QuickTransaction
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transactions_index(request):
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/pages/index.html",
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transactions_list(request):
|
||||
quick_transactions = QuickTransaction.objects.all().order_by("name")
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/list.html",
|
||||
context={"quick_transactions": quick_transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def quick_transaction_add(request):
|
||||
if request.method == "POST":
|
||||
form = QuickTransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = QuickTransactionForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def quick_transaction_edit(request, quick_transaction_id):
|
||||
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = QuickTransactionForm(request.POST, instance=quick_transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Item updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = QuickTransactionForm(instance=quick_transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/edit.html",
|
||||
{"form": form, "quick_transaction": quick_transaction},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["DELETE"])
|
||||
def quick_transaction_delete(request, quick_transaction_id):
|
||||
quick_transaction = get_object_or_404(QuickTransaction, id=quick_transaction_id)
|
||||
|
||||
quick_transaction.delete()
|
||||
|
||||
messages.success(request, _("Item deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transactions_create_menu(request):
|
||||
quick_transactions = QuickTransaction.objects.all().order_by("name")
|
||||
return render(
|
||||
request,
|
||||
"quick_transactions/fragments/create_menu.html",
|
||||
context={"quick_transactions": quick_transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def quick_transaction_add_as_transaction(request, quick_transaction_id):
|
||||
quick_transaction: QuickTransaction = get_object_or_404(
|
||||
QuickTransaction, id=quick_transaction_id
|
||||
)
|
||||
today = timezone.localdate(timezone.now())
|
||||
|
||||
quick_transaction_data = model_to_dict(
|
||||
quick_transaction,
|
||||
exclude=["id", "name", "owner", "account", "category", "tags", "entities"],
|
||||
)
|
||||
|
||||
new_transaction = Transaction(**quick_transaction_data)
|
||||
new_transaction.account = quick_transaction.account
|
||||
new_transaction.category = quick_transaction.category
|
||||
|
||||
new_transaction.date = today
|
||||
new_transaction.reference_date = today.replace(day=1)
|
||||
new_transaction.save()
|
||||
new_transaction.tags.set(quick_transaction.tags.all())
|
||||
new_transaction.entities.set(quick_transaction.entities.all())
|
||||
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "updated, hide_offcanvas",
|
||||
},
|
||||
)
|
||||
@@ -43,16 +43,71 @@ def transaction_add(request):
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
update = False
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
saved_instance = form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
if "submit" in request.POST:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "updated, hide_offcanvas"},
|
||||
)
|
||||
elif "submit_and_another" in request.POST:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
},
|
||||
)
|
||||
update = True
|
||||
elif "submit_and_similar" in request.POST:
|
||||
initial_data = {}
|
||||
|
||||
# Define fields to copy from the SAVED instance
|
||||
direct_fields_to_copy = [
|
||||
"account", # ForeignKey -> will copy the ID
|
||||
"type", # ChoiceField -> will copy the value
|
||||
"is_paid", # BooleanField -> will copy True/False
|
||||
"date", # DateField -> will copy the date object
|
||||
"reference_date", # DateField -> will copy the date object
|
||||
"amount", # DecimalField -> will copy the decimal
|
||||
"description", # CharField -> will copy the string
|
||||
"notes", # TextField -> will copy the string
|
||||
"category", # ForeignKey -> will copy the ID
|
||||
]
|
||||
m2m_fields_to_copy = [
|
||||
"tags", # ManyToManyField -> will copy list of IDs
|
||||
"entities", # ManyToManyField -> will copy list of IDs
|
||||
]
|
||||
|
||||
# Copy direct fields from the saved instance
|
||||
for field_name in direct_fields_to_copy:
|
||||
value = getattr(saved_instance, field_name, None)
|
||||
if value is not None:
|
||||
# Handle ForeignKey: use the pk
|
||||
if hasattr(value, "pk"):
|
||||
initial_data[field_name] = value.pk
|
||||
# Handle Date/DateTime/Decimal/Boolean/etc.: use the Python object directly
|
||||
else:
|
||||
initial_data[field_name] = (
|
||||
value # This correctly handles date objects!
|
||||
)
|
||||
|
||||
# Copy M2M fields: provide a list of related object pks
|
||||
for field_name in m2m_fields_to_copy:
|
||||
m2m_manager = getattr(saved_instance, field_name)
|
||||
initial_data[field_name] = list(
|
||||
m2m_manager.values_list("name", flat=True)
|
||||
)
|
||||
|
||||
# Create a new form instance pre-filled with the correctly typed initial data
|
||||
form = TransactionForm(initial=initial_data)
|
||||
update = True # Signal HTMX to update the form area
|
||||
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
@@ -61,11 +116,15 @@ def transaction_add(request):
|
||||
},
|
||||
)
|
||||
|
||||
return render(
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
if update:
|
||||
response["HX-Trigger"] = "updated"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,7 @@ from apps.users.forms import (
|
||||
UserAddForm,
|
||||
)
|
||||
from apps.users.models import UserSettings
|
||||
from apps.common.decorators.demo import disabled_on_demo
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
@@ -168,6 +169,7 @@ def user_add(request):
|
||||
|
||||
@only_htmx
|
||||
@htmx_login_required
|
||||
@disabled_on_demo
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def user_edit(request, pk):
|
||||
user = get_object_or_404(get_user_model(), id=pk)
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
3229
app/locale/sv/LC_MESSAGES/django.po
Normal file
3229
app/locale/sv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
3244
app/locale/uk/LC_MESSAGES/django.po
Normal file
3244
app/locale/uk/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,45 +13,47 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-4 flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_month from:window"
|
||||
href="{% url 'calendar' month=previous_month year=previous_year %}"><i
|
||||
class="fa-solid fa-chevron-left"></i></a>
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-4 flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_month from:window"
|
||||
href="{% url 'calendar' month=previous_month year=previous_year %}"><i
|
||||
class="fa-solid fa-chevron-left"></i></a>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center"
|
||||
hx-get="{% url 'month_year_picker' %}"
|
||||
hx-target="#generic-offcanvas-left"
|
||||
hx-trigger="click, date_picker from:window"
|
||||
hx-vals='{"month": {{ month }}, "year": {{ year }}, "for": "calendar", "field": "date"}' role="button">
|
||||
{{ month|month_name }} {{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_month from:window"
|
||||
href="{% url 'calendar' month=next_month year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center"
|
||||
hx-get="{% url 'month_year_picker' %}"
|
||||
hx-target="#generic-offcanvas-left"
|
||||
hx-trigger="click, date_picker from:window"
|
||||
hx-vals='{"month": {{ month }}, "year": {{ year }}, "for": "calendar", "field": "date"}' role="button">
|
||||
{{ month|month_name }} {{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_month from:window"
|
||||
href="{% url 'calendar' month=next_month year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-8">
|
||||
{# <c-ui.quick-transactions-buttons#}
|
||||
{# :year="year"#}
|
||||
{# :month="month"#}
|
||||
{# ></c-ui.quick-transactions-buttons>#}
|
||||
</div>
|
||||
</div>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-8">
|
||||
<c-ui.quick-transactions-buttons
|
||||
:year="year"
|
||||
:month="month"
|
||||
></c-ui.quick-transactions-buttons>
|
||||
<div class="row">
|
||||
<div class="show-loading" hx-get="{% url 'calendar_list' month=month year=year %}"
|
||||
hx-trigger="load, updated from:window, selective_update from:window"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="show-loading" hx-get="{% url 'calendar_list' month=month year=year %}" hx-trigger="load, updated from:window, selective_update from:window"></div>
|
||||
</div>
|
||||
</div>
|
||||
<c-ui.transactions_fab></c-ui.transactions_fab>
|
||||
{% endblock %}
|
||||
|
||||
33
app/templates/cotton/components/fab.html
Normal file
33
app/templates/cotton/components/fab.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div class="tw-min-h-16">
|
||||
<div
|
||||
id="fab-wrapper"
|
||||
class="tw-fixed tw-bottom-5 tw-right-5 tw-ml-auto tw-w-max tw-flex tw-flex-col tw-items-end mt-5">
|
||||
<div
|
||||
id="menu"
|
||||
class="tw-flex tw-flex-col tw-items-end tw-space-y-6 tw-transition-all tw-duration-300 tw-ease-in-out tw-opacity-0 tw-invisible tw-hidden tw-mb-2">
|
||||
|
||||
{{ slot }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary rounded-circle p-0 tw-w-12 tw-h-12 tw-flex tw-items-center tw-justify-center tw-shadow-lg hover:tw-shadow-xl focus:tw-shadow-xl tw-transition-all tw-duration-300 tw-ease-in-out"
|
||||
_="
|
||||
on click or focusout
|
||||
if #menu matches .tw-invisible and event.type === 'click'
|
||||
add .tw-rotate-45 to #fab-icon
|
||||
remove .tw-invisible from #menu
|
||||
remove .tw-hidden from #menu
|
||||
remove .tw-opacity-0 from #menu
|
||||
else
|
||||
wait 0.2s
|
||||
remove .tw-rotate-45 from #fab-icon
|
||||
add .tw-invisible to #menu
|
||||
add .tw-hidden to #menu
|
||||
add .tw-opacity-0 to #menu
|
||||
end
|
||||
"
|
||||
>
|
||||
<i id="fab-icon" class="fa-solid fa-plus tw-text-3xl tw-transition-transform tw-duration-300 tw-ease-in-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
11
app/templates/cotton/components/fab_menu_button.html
Normal file
11
app/templates/cotton/components/fab_menu_button.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% load i18n %}
|
||||
<div class="tw-relative fab-item">
|
||||
<button class="btn btn-sm btn-{{ color }}"
|
||||
hx-get="{{ url }}"
|
||||
hx-trigger="{{ hx_trigger }}"
|
||||
hx-target="{{ hx_target }}"
|
||||
hx-vals='{{ hx_vals }}'>
|
||||
<i class="{{ icon }} me-2"></i>
|
||||
{{ title }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -15,7 +15,8 @@
|
||||
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
|
||||
on mouseout add .tw-invisible to the first .transaction-actions in me end">
|
||||
<div class="row font-monospace tw-text-sm align-items-center">
|
||||
<div class="col-lg-auto col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center p-0 ps-1">
|
||||
<div
|
||||
class="col-lg-auto col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center p-0 ps-1">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="text-decoration-none p-3 tw-text-gray-500"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{% spaceless %}
|
||||
{% load i18n %}
|
||||
<span class="tw-text-xs text-white-50 mx-1"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ content }}">
|
||||
<i class="fa-solid fa-circle-question fa-fw"></i>
|
||||
<i class="{% if not icon %}fa-solid fa-circle-question{% else %}{{ icon }}{% endif %} fa-fw"></i>
|
||||
</span>
|
||||
{% endspaceless %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}{% include 'includes/help_icon.html' with content=help_text %}{% endif %}</h5>
|
||||
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}<c-ui.help-icon :content="help_text" icon=""></c-ui.help-icon>{% endif %}</h5>
|
||||
{{ slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
60
app/templates/cotton/ui/transactions_fab.html
Normal file
60
app/templates/cotton/ui/transactions_fab.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% load i18n %}
|
||||
<c-components.fab>
|
||||
<c-components.fab_menu_button
|
||||
color="success"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, add_income from:window"
|
||||
hx_vals='{"year": {{ year }}, {% if month %}"month": {{ month }},{% endif %} "type": "IN"}'
|
||||
url="{% url 'transaction_add' %}"
|
||||
icon="fa-solid fa-arrow-right-to-bracket"
|
||||
title="{% translate "Income" %}"></c-components.fab_menu_button>
|
||||
|
||||
<c-components.fab_menu_button
|
||||
color="danger"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, add_income from:window"
|
||||
hx_vals='{"year": {{ year }}, {% if month %}"month": {{ month }},{% endif %} "type": "EX"}'
|
||||
url="{% url 'transaction_add' %}"
|
||||
icon="fa-solid fa-arrow-right-from-bracket"
|
||||
title="{% translate "Expense" %}"></c-components.fab_menu_button>
|
||||
|
||||
<c-components.fab_menu_button
|
||||
color="warning"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, installment from:window"
|
||||
url="{% url 'installment_plan_add' %}"
|
||||
icon="fa-solid fa-divide"
|
||||
title="{% translate "Installment" %}"></c-components.fab_menu_button>
|
||||
|
||||
<c-components.fab_menu_button
|
||||
color="warning"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, recurring from:window"
|
||||
url="{% url 'recurring_transaction_add' %}"
|
||||
icon="fa-solid fa-repeat"
|
||||
title="{% translate "Recurring" %}"></c-components.fab_menu_button>
|
||||
|
||||
<c-components.fab_menu_button
|
||||
color="info"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, transfer from:window"
|
||||
hx_vals='{"year": {{ year }} {% if month %}, "month": {{ month }}{% endif %}}'
|
||||
url="{% url 'transactions_transfer' %}"
|
||||
icon="fa-solid fa-money-bill-transfer"
|
||||
title="{% translate "Transfer" %}"></c-components.fab_menu_button>
|
||||
|
||||
<c-components.fab_menu_button
|
||||
color="info"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, balance from:window"
|
||||
url="{% url 'account_reconciliation' %}"
|
||||
icon="fa-solid fa-scale-balanced"
|
||||
title="{% translate "Balance" %}"></c-components.fab_menu_button>
|
||||
<c-components.fab_menu_button
|
||||
color="secondary"
|
||||
hx_target="#generic-offcanvas"
|
||||
hx_trigger="click, quick_transaction from:window"
|
||||
url="{% url 'quick_transactions_create_menu' %}"
|
||||
icon="fa-solid fa-person-running"
|
||||
title="{% translate "Quick Transaction" %}"></c-components.fab_menu_button>
|
||||
</c-components.fab>
|
||||
@@ -50,7 +50,7 @@
|
||||
<a class="nav-link {% active_link views='insights_index' %}" href="{% url 'insights_index' %}">{% trans 'Insights' %}</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
|
||||
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||quick_transactions_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
@@ -68,6 +68,8 @@
|
||||
{% endif %}
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li><a class="dropdown-item {% active_link views='quick_transactions_index' %}"
|
||||
href="{% url 'quick_transactions_index' %}">{% translate 'Quick Transactions' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='installment_plans_index' %}"
|
||||
href="{% url 'installment_plans_index' %}">{% translate 'Installment Plans' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='recurring_trasanctions_index' %}"
|
||||
@@ -94,7 +96,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% 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' %}"
|
||||
<a class="nav-link dropdown-toggle {% 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' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<div id="toasts">
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" hx-trigger="load, updated from:window, toasts from:window" hx-get="{% url 'toasts' %}" hx-swap="beforeend">
|
||||
<div class="toast-container position-fixed bottom-0 start-50 translate-middle-x p-3" hx-trigger="load, updated from:window, toasts from:window" hx-get="{% url 'toasts' %}" hx-swap="beforeend">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,226 +1,431 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
||||
hx-include="#picker-form, #picker-type">
|
||||
hx-include="#picker-form, #picker-type, #view-type, #show-tags, #showing">
|
||||
<div class="h-100 text-center mb-4">
|
||||
<div class="btn-group gap-3" role="group" id="view-type" _="on change trigger updated">
|
||||
<input type="radio" class="btn-check"
|
||||
name="view_type"
|
||||
id="table-view"
|
||||
autocomplete="off"
|
||||
value="table"
|
||||
{% if view_type == "table" %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary rounded-5" for="table-view"><i
|
||||
class="fa-solid fa-table fa-fw me-2"></i>{% trans 'Table' %}</label>
|
||||
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="view_type"
|
||||
id="bars-view"
|
||||
autocomplete="off"
|
||||
value="bars"
|
||||
{% if view_type == "bars" %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary rounded-5" for="bars-view"><i
|
||||
class="fa-solid fa-chart-bar fa-fw me-2"></i>{% trans 'Bars' %}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 mb-1 d-flex flex-column flex-md-row justify-content-between">
|
||||
<div class="form-check form-switch" id="show-tags">
|
||||
{% if view_type == 'table' %}
|
||||
<input type="hidden" name="show_tags" value="off">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="show-tags-switch" name="show_tags"
|
||||
_="on change trigger updated" {% if show_tags %}checked{% endif %}>
|
||||
{% spaceless %}
|
||||
<label class="form-check-label" for="show-tags-switch">
|
||||
{% trans 'Tags' %}
|
||||
</label>
|
||||
<c-ui.help-icon
|
||||
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
|
||||
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
||||
{% endspaceless %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group" id="showing" _="on change trigger updated">
|
||||
<input type="radio" class="btn-check" name="showing" id="showing-projected" autocomplete="off"
|
||||
value="projected" {% if showing == 'projected' %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary" for="showing-projected">{% trans "Projected" %}</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="showing" id="showing-current" autocomplete="off" value="current"
|
||||
{% if showing == 'current' %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary" for="showing-current">{% trans "Current" %}</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="showing" id="showing-final" autocomplete="off" value="final"
|
||||
{% if showing == 'final' %}checked{% endif %}>
|
||||
<label class="btn btn-outline-primary" for="showing-final">{% trans "Final total" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% if total_table %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans 'Category' %}</th>
|
||||
<th scope="col">{% trans 'Income' %}</th>
|
||||
<th scope="col">{% trans 'Expense' %}</th>
|
||||
<th scope="col">{% trans 'Total' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in total_table.values %}
|
||||
{% if view_type == "table" %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover table-bordered align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
|
||||
<td>
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if currency.total_income != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_income"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if currency.total_expense != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_expense"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if currency.total_final != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<th scope="col">{% trans 'Category' %}</th>
|
||||
<th scope="col">{% trans 'Income' %}</th>
|
||||
<th scope="col">{% trans 'Expense' %}</th>
|
||||
<th scope="col">{% trans 'Total' %}</th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
{% for category in total_table.values %}
|
||||
<!-- Category row -->
|
||||
<tr class="table-group-header">
|
||||
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
|
||||
<td> {# income #}
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if showing == 'current' and currency.income_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_income != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_income"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td> {# expenses #}
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_expense != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_expense"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td> {# total #}
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if showing == 'current' and currency.total_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_final != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="chart-container" _="init call setupChart() end" style="position: relative; height:90vh; width:100%">
|
||||
<canvas id="categoryChart"></canvas>
|
||||
<!-- Tag rows -->
|
||||
{% if show_tags %}
|
||||
{% for tag_id, tag in category.tags.items %}
|
||||
{% if tag.name or not tag.name and category.tags.values|length > 1 %}
|
||||
<tr class="table-row-nested">
|
||||
<td class="ps-4">
|
||||
<i class="fa-solid fa-hashtag fa-fw me-2 text-muted"></i>{% if tag.name %}{{ tag.name }}{% else %}{% trans 'Untagged' %}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in tag.currencies.values %}
|
||||
{% if showing == 'current' and currency.income_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.income_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_income != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_income"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in tag.currencies.values %}
|
||||
{% if showing == 'current' and currency.expense_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.expense_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_expense != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_expense"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in tag.currencies.values %}
|
||||
{% if showing == 'current' and currency.total_current != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_current"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_projected"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% elif showing == 'final' and currency.total_final != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ total_table|json_script:"categoryOverviewData" }}
|
||||
|
||||
<script>
|
||||
function setupChart() {
|
||||
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
|
||||
{% elif view_type == "bars" %}
|
||||
<div>
|
||||
<div class="chart-container" _="init call setupChart() end" style="position: relative; height:78vh; width:100%">
|
||||
<canvas id="categoryChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// --- Dynamic Data Processing ---
|
||||
var categories = [];
|
||||
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
|
||||
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
|
||||
{{ total_table|json_script:"categoryOverviewData" }}
|
||||
{{ showing|json_script:"showingString" }}
|
||||
|
||||
// Pass 1: Collect categories and currency details
|
||||
Object.values(rawData).forEach(cat => {
|
||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
||||
if (!categories.includes(categoryName)) {
|
||||
categories.push(categoryName);
|
||||
}
|
||||
if (cat.currencies) {
|
||||
Object.values(cat.currencies).forEach(curr => {
|
||||
var details = curr.currency;
|
||||
if (details && details.code && !currencyDetails[details.code]) {
|
||||
var decimals = parseInt(details.decimal_places, 10);
|
||||
currencyDetails[details.code] = {
|
||||
code: details.code,
|
||||
name: details.name || details.code,
|
||||
prefix: details.prefix || '',
|
||||
suffix: details.suffix || '',
|
||||
// Ensure decimal_places is a non-negative integer
|
||||
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
<script>
|
||||
function setupChart() {
|
||||
var rawData = JSON.parse(document.getElementById('categoryOverviewData').textContent);
|
||||
var showing_string = JSON.parse(document.getElementById('showingString').textContent);
|
||||
console.log(showing_string)
|
||||
|
||||
// Initialize data structure for each currency with nulls
|
||||
Object.keys(currencyDetails).forEach(code => {
|
||||
currencyData[code] = new Array(categories.length).fill(null);
|
||||
});
|
||||
// --- Dynamic Data Processing ---
|
||||
var categories = [];
|
||||
var currencyDetails = {}; // Stores details like { BRL: {code: 'BRL', name: 'Real', ...}, ... }
|
||||
var currencyData = {}; // Stores data arrays like { BRL: [val1, null, val3,...], ... }
|
||||
|
||||
// Pass 2: Populate data arrays (store all valid numbers now)
|
||||
Object.values(rawData).forEach(cat => {
|
||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
||||
var catIndex = categories.indexOf(categoryName);
|
||||
if (catIndex === -1) return;
|
||||
// Pass 1: Collect categories and currency details
|
||||
Object.values(rawData).forEach(cat => {
|
||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
||||
if (!categories.includes(categoryName)) {
|
||||
categories.push(categoryName);
|
||||
}
|
||||
if (cat.currencies) {
|
||||
Object.values(cat.currencies).forEach(curr => {
|
||||
var details = curr.currency;
|
||||
if (details && details.code && !currencyDetails[details.code]) {
|
||||
var decimals = parseInt(details.decimal_places, 10);
|
||||
currencyDetails[details.code] = {
|
||||
code: details.code,
|
||||
name: details.name || details.code,
|
||||
prefix: details.prefix || '',
|
||||
suffix: details.suffix || '',
|
||||
// Ensure decimal_places is a non-negative integer
|
||||
decimal_places: !isNaN(decimals) && decimals >= 0 ? decimals : 2
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (cat.currencies) {
|
||||
Object.values(cat.currencies).forEach(curr => {
|
||||
var code = curr.currency?.code;
|
||||
if (code && currencyData[code]) {
|
||||
var value = parseFloat(curr.total_final);
|
||||
// Store the number if it's valid, otherwise keep null
|
||||
currencyData[code][catIndex] = !isNaN(value) ? value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Initialize data structure for each currency with nulls
|
||||
Object.keys(currencyDetails).forEach(code => {
|
||||
currencyData[code] = new Array(categories.length).fill(null);
|
||||
});
|
||||
|
||||
// --- Dynamic Chart Configuration ---
|
||||
var datasets = Object.keys(currencyDetails).map((code, index) => {
|
||||
return {
|
||||
label: currencyDetails[code].name, // Use currency name for the legend label
|
||||
data: currencyData[code],
|
||||
currencyCode: code, // Store code for easy lookup in tooltip
|
||||
borderWidth: 1
|
||||
};
|
||||
});
|
||||
// Pass 2: Populate data arrays (store all valid numbers now)
|
||||
Object.values(rawData).forEach(cat => {
|
||||
var categoryName = cat.name === null ? "{% trans 'Uncategorized' %}" : cat.name;
|
||||
var catIndex = categories.indexOf(categoryName);
|
||||
if (catIndex === -1) return;
|
||||
|
||||
new Chart(document.getElementById('categoryChart'),
|
||||
{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: categories,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: "y"
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const dataset = context.dataset;
|
||||
const currencyCode = dataset.currencyCode;
|
||||
const details = currencyDetails[currencyCode];
|
||||
const value = context.parsed.x; // Use 'x' because indexAxis is 'y'
|
||||
if (cat.currencies) {
|
||||
Object.values(cat.currencies).forEach(curr => {
|
||||
var code = curr.currency?.code;
|
||||
if (code && currencyData[code]) {
|
||||
if (showing_string == 'current') {
|
||||
var value = parseFloat(curr.total_current);
|
||||
} else if (showing_string == 'projected') {
|
||||
var value = parseFloat(curr.total_projected);
|
||||
} else {
|
||||
var value = parseFloat(curr.total_final);
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || !details) {
|
||||
// Display the category name if the value is null/undefined
|
||||
return null;
|
||||
}
|
||||
// Store the number if it's valid, otherwise keep null
|
||||
currencyData[code][catIndex] = !isNaN(value) ? value : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let formattedValue = '';
|
||||
try {
|
||||
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
|
||||
formattedValue = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: details.decimal_places,
|
||||
maximumFractionDigits: details.decimal_places,
|
||||
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
|
||||
}).format(value);
|
||||
} catch (e) {
|
||||
formattedValue = value.toFixed(details.decimal_places);
|
||||
}
|
||||
// --- Dynamic Chart Configuration ---
|
||||
var datasets = Object.keys(currencyDetails).map((code, index) => {
|
||||
return {
|
||||
label: currencyDetails[code].name, // Use currency name for the legend label
|
||||
data: currencyData[code],
|
||||
currencyCode: code, // Store code for easy lookup in tooltip
|
||||
borderWidth: 1
|
||||
};
|
||||
});
|
||||
|
||||
// Return label with currency name and formatted value including prefix/suffix
|
||||
return `${details.prefix}${formattedValue}${details.suffix}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans 'Final Total' %}'
|
||||
},
|
||||
ticks: {
|
||||
// Format ticks using the detected locale
|
||||
callback: function (value, index, ticks) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: false,
|
||||
text: '{% trans 'Category' %}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
new Chart(document.getElementById('categoryChart'),
|
||||
{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: categories,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: "y"
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const dataset = context.dataset;
|
||||
const currencyCode = dataset.currencyCode;
|
||||
const details = currencyDetails[currencyCode];
|
||||
const value = context.parsed.x; // Use 'x' because indexAxis is 'y'
|
||||
|
||||
if (value === null || value === undefined || !details) {
|
||||
// Display the category name if the value is null/undefined
|
||||
return null;
|
||||
}
|
||||
|
||||
let formattedValue = '';
|
||||
try {
|
||||
// Use Intl.NumberFormat for ALL values, configured with locale and exact decimal places
|
||||
formattedValue = new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: details.decimal_places,
|
||||
maximumFractionDigits: details.decimal_places,
|
||||
// Do NOT use style: 'currency' here, as we add prefix/suffix manually
|
||||
}).format(value);
|
||||
} catch (e) {
|
||||
formattedValue = value.toFixed(details.decimal_places);
|
||||
}
|
||||
|
||||
// Return label with currency name and formatted value including prefix/suffix
|
||||
return `${details.prefix}${formattedValue}${details.suffix}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
type: 'linear',
|
||||
title: {
|
||||
display: true,
|
||||
text: '{% trans 'Final Total' %}'
|
||||
},
|
||||
ticks: {
|
||||
// Format ticks using the detected locale
|
||||
callback: function (value, index, ticks) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: false,
|
||||
text: '{% trans 'Category' %}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Insights' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row my-3 h-100">
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-8">
|
||||
<c-ui.quick-transactions-buttons
|
||||
:year="year"
|
||||
:month="month"
|
||||
></c-ui.quick-transactions-buttons>
|
||||
</div>
|
||||
{# <div class="col-12 col-xl-8">#}
|
||||
{# <c-ui.quick-transactions-buttons#}
|
||||
{# :year="year"#}
|
||||
{# :month="month"#}
|
||||
{# ></c-ui.quick-transactions-buttons>#}
|
||||
{# </div>#}
|
||||
</div>
|
||||
{# Monthly summary#}
|
||||
<div class="row gx-xl-4 gy-3">
|
||||
@@ -174,8 +174,9 @@
|
||||
</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"
|
||||
_="on input or search or htmx:afterSwap from window
|
||||
<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
|
||||
@@ -195,4 +196,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<c-ui.transactions_fab></c-ui.transactions_fab>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
color="grey"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if currency.consolidated %}
|
||||
{% if currency.consolidated and currency.consolidated.total_final != currency.total_final %}
|
||||
<div class="d-flex align-items-baseline w-100">
|
||||
<div class="account-name text-start font-monospace tw-text-gray-300">
|
||||
<span class="hierarchy-line-icon"></span>{% trans 'Consolidated' %}</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
:prefix="currency.consolidated.currency.prefix"
|
||||
:suffix="currency.consolidated.currency.suffix"
|
||||
:decimal_places="currency.consolidated.currency.decimal_places"
|
||||
color="{% if currency.total_final > 0 %}green{% elif currency.total_final < 0 %}red{% endif %}"
|
||||
color="{% if currency.consolidated.total_final > 0 %}green{% elif currency.consolidated.total_final < 0 %}red{% endif %}"
|
||||
text-end></c-amount.display>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
11
app/templates/quick_transactions/fragments/add.html
Normal file
11
app/templates/quick_transactions/fragments/add.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Add quick transaction' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'quick_transaction_add' %}" hx-target="#generic-offcanvas" novalidate>
|
||||
{% crispy form %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
17
app/templates/quick_transactions/fragments/create_menu.html
Normal file
17
app/templates/quick_transactions/fragments/create_menu.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Add quick transaction' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for qt in quick_transactions %}
|
||||
<a hx-get="{% url 'quick_transaction_add_as_transaction' quick_transaction_id=qt.id %}"
|
||||
class="list-group-item list-group-item-action tw-cursor-pointer {% if qt.type == 'EX' %}!tw-text-red-400{% else %}!tw-text-green-400{% endif %}">{{ qt.name }}</a>
|
||||
{% empty %}
|
||||
<c-msg.empty title="{% translate "Nothing to see here..." %}" remove-padding></c-msg.empty>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
13
app/templates/quick_transactions/fragments/edit.html
Normal file
13
app/templates/quick_transactions/fragments/edit.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Edit quick transaction' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'quick_transaction_edit' quick_transaction_id=quick_transaction.id %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
novalidate>
|
||||
{% crispy form %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
59
app/templates/quick_transactions/fragments/list.html
Normal file
59
app/templates/quick_transactions/fragments/list.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% load i18n %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div id="quick-transactions-table">
|
||||
{% if quick_transactions %}
|
||||
<c-config.search></c-config.search>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="col-auto"></th>
|
||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for qt in quick_transactions %}
|
||||
<tr class="recurring_transaction">
|
||||
<td class="col-auto text-center">
|
||||
<div class="btn-group" role="group" aria-label="{% translate 'Actions' %}">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'quick_transaction_edit' quick_transaction_id=qt.id %}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm text-danger"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'quick_transaction_delete' quick_transaction_id=qt.id %}"
|
||||
hx-trigger='confirmed'
|
||||
hx-swap="innerHTML"
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "This will delete this item" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="col">
|
||||
<div
|
||||
class="{% if qt.type == 'EX' %}tw-text-red-400{% else %}tw-text-green-400{% endif %}">
|
||||
{{ qt.name }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "Nothing to see here..." %}" remove-padding></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
25
app/templates/quick_transactions/pages/index.html
Normal file
25
app/templates/quick_transactions/pages/index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Quick Transactions' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
|
||||
{% spaceless %}
|
||||
<div>{% translate 'Quick Transactions' %}<span>
|
||||
<a class="text-decoration-none tw-text-2xl p-1 category-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Add" %}"
|
||||
hx-get="{% url 'quick_transaction_add' %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
|
||||
</span></div>
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
|
||||
<div id="quick-transactions-table" class="show-loading" hx-get="{% url 'quick_transactions_list' %}" hx-trigger="load, updated from:window"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load settings %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load socialaccount %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
@@ -25,6 +26,24 @@
|
||||
<div class="card-body">
|
||||
<h1 class="h2 card-title text-center mb-4">Login</h1>
|
||||
{% crispy form %}
|
||||
|
||||
{% get_providers as socialaccount_providers %}
|
||||
{% if socialaccount_providers %}
|
||||
<div class="mt-3">
|
||||
<hr>
|
||||
<ul class="socialaccount_providers list-unstyled">
|
||||
{% for provider in socialaccount_providers %}
|
||||
<li class="mt-2">
|
||||
<a title="{{ provider.name }}"
|
||||
class="btn btn-outline-primary w-100 socialaccount_provider {{ provider.id }}"
|
||||
href="{% provider_login_url provider %}">
|
||||
{% translate 'Login with' %} {{ provider.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,55 +12,56 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-2 flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_year from:window"
|
||||
href="{% url 'yearly_overview_account' year=previous_year %}">
|
||||
<i class="fa-solid fa-chevron-left"></i></a>
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-2 flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_year from:window"
|
||||
href="{% url 'yearly_overview_account' year=previous_year %}">
|
||||
<i class="fa-solid fa-chevron-left"></i></a>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center">
|
||||
{{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_year from:window"
|
||||
href="{% url 'yearly_overview_account' year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center">
|
||||
{{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_year from:window"
|
||||
href="{% url 'yearly_overview_account' year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-10">
|
||||
{# <c-ui.quick-transactions-buttons#}
|
||||
{# :year="year"#}
|
||||
{# :month="month"#}
|
||||
{# ></c-ui.quick-transactions-buttons>#}
|
||||
</div>
|
||||
</div>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-10">
|
||||
<c-ui.quick-transactions-buttons
|
||||
:year="year"
|
||||
:month="month"
|
||||
></c-ui.quick-transactions-buttons>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-2">
|
||||
<div class="nav flex-column nav-pills" id="month-pills" role="tablist" aria-orientation="vertical" hx-indicator="#data-content">
|
||||
<input type="hidden" name="month" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_account_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=month]').value = ''">
|
||||
{% translate 'Year' %}
|
||||
</button>
|
||||
{% for month in months %}
|
||||
<div class="row">
|
||||
<div class="col-lg-2">
|
||||
<div class="nav flex-column nav-pills" id="month-pills" role="tablist" aria-orientation="vertical"
|
||||
hx-indicator="#data-content">
|
||||
<input type="hidden" name="month" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_account_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=month]').value = ''">
|
||||
{% translate 'Year' %}
|
||||
</button>
|
||||
{% for month in months %}
|
||||
<button class="nav-link"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
@@ -70,28 +71,29 @@
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=month]').value = '{{ month }}'">
|
||||
{{ month|month_name }}
|
||||
{{ month|month_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 d-block d-lg-none">
|
||||
<div class="col-lg-3">
|
||||
<div class="nav flex-column nav-pills" id="currency-pills" role="tablist" aria-orientation="vertical" hx-indicator="#data-content">
|
||||
<input type="hidden" name="account" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_account_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=account]').value = ''">
|
||||
{% translate 'All' %}
|
||||
</button>
|
||||
{% for account in accounts %}
|
||||
<hr class="my-4 d-block d-lg-none">
|
||||
<div class="col-lg-3">
|
||||
<div class="nav flex-column nav-pills" id="currency-pills" role="tablist" aria-orientation="vertical"
|
||||
hx-indicator="#data-content">
|
||||
<input type="hidden" name="account" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_account_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=account]').value = ''">
|
||||
{% translate 'All' %}
|
||||
</button>
|
||||
{% for account in accounts %}
|
||||
<button class="nav-link"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
@@ -101,13 +103,13 @@
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=account]').value = '{{ account.id }}'">
|
||||
<span class="badge text-bg-primary me-2">{{ account.group.name }}</span>{{ account.name }}
|
||||
<span class="badge text-bg-primary me-2">{{ account.group.name }}</span>{{ account.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="col-lg-7">
|
||||
<div id="data-content"
|
||||
class="show-loading"
|
||||
hx-get="{% url 'yearly_overview_account_data' year=year %}"
|
||||
@@ -115,7 +117,8 @@
|
||||
hx-include="[name='account'], [name='month']"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<c-ui.transactions_fab></c-ui.transactions_fab>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,55 +14,56 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-2 flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_year from:window"
|
||||
href="{% url 'yearly_overview_currency' year=previous_year %}">
|
||||
<i class="fa-solid fa-chevron-left"></i></a>
|
||||
<div class="container px-md-3 py-3 column-gap-5">
|
||||
<div class="row mb-3 gx-xl-4 gy-3 mb-4">
|
||||
{# Date picker#}
|
||||
<div class="col-12 col-xl-2 flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="pe-4 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, previous_year from:window"
|
||||
href="{% url 'yearly_overview_currency' year=previous_year %}">
|
||||
<i class="fa-solid fa-chevron-left"></i></a>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center">
|
||||
{{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_year from:window"
|
||||
href="{% url 'yearly_overview_currency' year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold font-monospace tw-w-full text-center">
|
||||
{{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button"
|
||||
class="ps-3 py-2"
|
||||
hx-boost="true"
|
||||
hx-trigger="click, next_year from:window"
|
||||
href="{% url 'yearly_overview_currency' year=next_year %}">
|
||||
<i class="fa-solid fa-chevron-right"></i>
|
||||
</a>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-10">
|
||||
{# <c-ui.quick-transactions-buttons#}
|
||||
{# :year="year"#}
|
||||
{# :month="month"#}
|
||||
{# ></c-ui.quick-transactions-buttons>#}
|
||||
</div>
|
||||
</div>
|
||||
{# Action buttons#}
|
||||
<div class="col-12 col-xl-10">
|
||||
<c-ui.quick-transactions-buttons
|
||||
:year="year"
|
||||
:month="month"
|
||||
></c-ui.quick-transactions-buttons>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-2">
|
||||
<div class="nav flex-column nav-pills" id="month-pills" role="tablist" aria-orientation="vertical" hx-indicator="#data-content">
|
||||
<input type="hidden" name="month" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=month]').value = ''">
|
||||
{% translate 'Year' %}
|
||||
</button>
|
||||
{% for month in months %}
|
||||
<div class="row">
|
||||
<div class="col-lg-2">
|
||||
<div class="nav flex-column nav-pills" id="month-pills" role="tablist" aria-orientation="vertical"
|
||||
hx-indicator="#data-content">
|
||||
<input type="hidden" name="month" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=month]').value = ''">
|
||||
{% translate 'Year' %}
|
||||
</button>
|
||||
{% for month in months %}
|
||||
<button class="nav-link"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
@@ -72,28 +73,29 @@
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=month]').value = '{{ month }}'">
|
||||
{{ month|month_name }}
|
||||
{{ month|month_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 d-block d-lg-none">
|
||||
<div class="col-lg-3">
|
||||
<div class="nav flex-column nav-pills" id="currency-pills" role="tablist" aria-orientation="vertical" hx-indicator="#data-content">
|
||||
<input type="hidden" name="currency" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=currency]').value = ''">
|
||||
{% translate 'All' %}
|
||||
</button>
|
||||
{% for currency in currencies %}
|
||||
<hr class="my-4 d-block d-lg-none">
|
||||
<div class="col-lg-3">
|
||||
<div class="nav flex-column nav-pills" id="currency-pills" role="tablist" aria-orientation="vertical"
|
||||
hx-indicator="#data-content">
|
||||
<input type="hidden" name="currency" value="">
|
||||
<button class="nav-link active"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
hx-trigger="click"
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=currency]').value = ''">
|
||||
{% translate 'All' %}
|
||||
</button>
|
||||
{% for currency in currencies %}
|
||||
<button class="nav-link"
|
||||
role="tab"
|
||||
data-bs-toggle="pill"
|
||||
@@ -103,13 +105,13 @@
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelector('[name=currency]').value = '{{ currency.id }}'">
|
||||
{{ currency.name }}
|
||||
{{ currency.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="col-lg-7">
|
||||
<div id="data-content"
|
||||
class="show-loading"
|
||||
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
|
||||
@@ -117,7 +119,8 @@
|
||||
hx-include="[name='currency'], [name='month']"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<c-ui.transactions_fab></c-ui.transactions_fab>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/* custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
|
||||
@@ -35,3 +35,6 @@ $min-contrast-ratio: 1.9 !default;
|
||||
|
||||
$nav-pills-link-active-color: $gray-900;
|
||||
$dropdown-link-active-color: $gray-900;
|
||||
|
||||
$body-bg-dark: #1e1f24 !default;
|
||||
$body-tertiary-bg-dark: #232429 !default;
|
||||
|
||||
@@ -21,6 +21,7 @@ watchfiles==0.24.0 # https://github.com/samuelcolvin/watchfiles
|
||||
procrastinate[django]~=2.15.1
|
||||
|
||||
requests~=2.32.3
|
||||
django-allauth[socialaccount]~=65.9.0
|
||||
|
||||
pytz
|
||||
python-dateutil~=2.9.0.post0
|
||||
|
||||
Reference in New Issue
Block a user