Compare commits

...

29 Commits
0.18.1 ... dev

Author SHA1 Message Date
Herculino Trotta
50e5492ea1 feat(automatic-exchange-rate): track unsuccessful runs 2026-01-10 14:10:21 -03:00
Herculino Trotta
b074ef7929 feat: add late section to monthly and all views (w/ default ordering) 2026-01-10 02:52:46 -03:00
Herculino Trotta
ff4bd79634 fix(dca): strategy api endpoint returns nothing 2026-01-09 23:51:31 -03:00
Herculino Trotta
dd6a390e6b fix(transactions): empty internal_id raises duplicate error when editing via django admin 2026-01-09 23:25:13 -03:00
Herculino Trotta
b455a0251a fix(import_restore): unable to restore installment plans when there's multiple accounts with the same name 2025-12-30 21:59:29 -03:00
Herculino Trotta
6c90e1bb7f refactor: improve month by month and year by year value display 2025-12-30 21:58:12 -03:00
Herculino Trotta
39f66b620a feat(insights): new month by month insight 2025-12-28 22:57:29 -03:00
Herculino Trotta
92cf526b76 feat(insights): new year by year insight 2025-12-28 22:55:58 -03:00
Herculino Trotta
700d35b5d5 feat(tests): add tests for monthly summaries 2025-12-28 13:36:21 -03:00
Herculino Trotta
01f91352d6 feat(transactions:filter): make montlhy summary filter-aware 2025-12-28 13:20:25 -03:00
Herculino Trotta
a2871d5289 feat(transactions:filter): add filter for muted and unmuted transactions 2025-12-28 13:09:41 -03:00
Herculino Trotta
2076903740 refactor: order management lists by name instead of id 2025-12-27 23:43:57 -03:00
Herculino Trotta
c7ff6db0bf feat(app): add sanity checks for env variables 2025-12-26 09:55:57 -03:00
Herculino Trotta
05ede58c36 fix: "lax" deduplication fails if the comparison field has a numeric value 2025-12-20 00:17:22 -03:00
Herculino Trotta
b0101dae1a fix: input fields with text inside looks wrong 2025-12-20 00:08:56 -03:00
Herculino Trotta
edcad37926 fix: recurring transactions not adding entities or tags to created transactions 2025-12-19 23:55:30 -03:00
Herculino Trotta
94f5c25829 fix: datepicker doesn't recalculate position when changing view mode 2025-12-19 22:21:03 -03:00
Herculino Trotta
3dce9e1c55 feat: speedup startup by moving collectstatic to the Dockerfile 2025-12-19 22:13:05 -03:00
Herculino Trotta
0545fb7651 fix: try to fix stale database connections (again) 2025-12-19 21:59:55 -03:00
Herculino Trotta
f918351303 fix: user settings form 2025-12-14 12:47:36 -03:00
Herculino Trotta
8f06c06d32 fix: extra space on some translations 2025-12-14 11:56:20 -03:00
Herculino Trotta
67f79effab feat: improve text for rules 2025-12-14 11:43:52 -03:00
Herculino Trotta
c168886968 feat: improve text for rules 2025-12-14 11:42:51 -03:00
Herculino Trotta
4aa29545ec feat: remove bootstrap's collapses; improve animations 2025-12-14 11:06:55 -03:00
Herculino Trotta
cf7d4b1404 feat: improve transactions action bar animation 2025-12-13 20:47:51 -03:00
Herculino Trotta
606e6b3843 fix(style): demo mode close button is place incorrectly 2025-12-13 16:45:57 -03:00
Herculino Trotta
29b6ee3af3 feat: add "invert selection" option to transactions action bar 2025-12-13 16:35:53 -03:00
Herculino Trotta
938c128d07 fix: show muted transactions/categories on account and currency flow. 2025-12-13 16:18:19 -03:00
Herculino Trotta
dc33fda5d3 feat: prevent background tasks from running all at once 2025-12-13 15:07:38 -03:00
70 changed files with 3372 additions and 1081 deletions

View File

@@ -143,6 +143,9 @@ WSGI_APPLICATION = "WYGIWYH.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
THREADS = int(os.getenv("GUNICORN_THREADS", 1))
MAX_POOL_SIZE = THREADS + 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
@@ -151,8 +154,16 @@ DATABASES = {
"PASSWORD": os.getenv("SQL_PASSWORD", "password"),
"HOST": os.getenv("SQL_HOST", "localhost"),
"PORT": os.getenv("SQL_PORT", "5432"),
"CONN_MAX_AGE": 0,
"CONN_HEALTH_CHECKS": True,
"OPTIONS": {
"pool": True,
"pool": {
"min_size": 1,
"max_size": MAX_POOL_SIZE,
"timeout": 10,
"max_lifetime": 600,
"max_idle": 300,
},
},
}
}

View File

@@ -25,7 +25,7 @@ def account_groups_index(request):
@login_required
@require_http_methods(["GET"])
def account_groups_list(request):
account_groups = AccountGroup.objects.all().order_by("id")
account_groups = AccountGroup.objects.all().order_by("name")
return render(
request,
"account_groups/fragments/list.html",

View File

@@ -25,7 +25,7 @@ def accounts_index(request):
@login_required
@require_http_methods(["GET"])
def accounts_list(request):
accounts = Account.objects.all().order_by("id")
accounts = Account.objects.all().order_by("name")
return render(
request,
"accounts/fragments/list.html",

View File

@@ -9,6 +9,9 @@ class DCAStrategyViewSet(viewsets.ModelViewSet):
queryset = DCAStrategy.objects.all()
serializer_class = DCAStrategySerializer
def get_queryset(self):
return DCAStrategy.objects.all().order_by("id")
@action(detail=True, methods=["get"])
def investment_frequency(self, request, pk=None):
strategy = self.get_object()

View File

@@ -23,3 +23,6 @@ class CommonConfig(AppConfig):
# Delete the cache for update checks to prevent false-positives when the app is restarted
# this will be recreated by the check_for_updates task
cache.delete("update_check")
# Register system checks for required environment variables
from apps.common import checks # noqa: F401

103
app/apps/common/checks.py Normal file
View File

@@ -0,0 +1,103 @@
"""
Django System Checks for required environment variables.
This module validates that required environment variables (those without defaults)
are present before the application starts.
"""
import os
from django.core.checks import Error, register
# List of environment variables that are required (no default values)
# Based on the README.md documentation
REQUIRED_ENV_VARS = [
("SECRET_KEY", "This is used to provide cryptographic signing."),
("SQL_DATABASE", "The name of your postgres database."),
]
# List of environment variables that must be valid integers if set
INT_ENV_VARS = [
("TASK_WORKERS", "How many workers to have for async tasks."),
("SESSION_EXPIRY_TIME", "The age of session cookies, in seconds."),
("INTERNAL_PORT", "The port on which the app listens on."),
("DJANGO_VITE_DEV_SERVER_PORT", "The port where Vite's dev server is running"),
]
@register()
def check_required_env_vars(app_configs, **kwargs):
"""
Check that all required environment variables are set.
Returns a list of Error objects for any missing required variables.
"""
errors = []
for var_name, description in REQUIRED_ENV_VARS:
value = os.getenv(var_name)
if not value:
errors.append(
Error(
f"Required environment variable '{var_name}' is not set.",
hint=f"{description} Please set this variable in your .env file or environment.",
id="wygiwyh.E001",
)
)
return errors
@register()
def check_int_env_vars(app_configs, **kwargs):
"""
Check that environment variables that should be integers are valid.
Returns a list of Error objects for any invalid integer variables.
"""
errors = []
for var_name, description in INT_ENV_VARS:
value = os.getenv(var_name)
if value is not None:
try:
int(value)
except ValueError:
errors.append(
Error(
f"Environment variable '{var_name}' must be a valid integer, got '{value}'.",
hint=f"{description}",
id="wygiwyh.E002",
)
)
return errors
@register()
def check_soft_delete_config(app_configs, **kwargs):
"""
Check that KEEP_DELETED_TRANSACTIONS_FOR is a valid integer when ENABLE_SOFT_DELETE is enabled.
Returns a list of Error objects if the configuration is invalid.
"""
errors = []
enable_soft_delete = os.getenv("ENABLE_SOFT_DELETE", "false").lower() == "true"
if enable_soft_delete:
keep_deleted_for = os.getenv("KEEP_DELETED_TRANSACTIONS_FOR")
if keep_deleted_for is not None:
try:
int(keep_deleted_for)
except ValueError:
errors.append(
Error(
f"Environment variable 'KEEP_DELETED_TRANSACTIONS_FOR' must be a valid integer when ENABLE_SOFT_DELETE is enabled, got '{keep_deleted_for}'.",
hint="Time in days to keep soft deleted transactions for. Set to 0 to keep all transactions indefinitely.",
id="wygiwyh.E003",
)
)
return errors

View File

@@ -17,7 +17,12 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 4 * * *")
@app.task(queueing_lock="remove_old_jobs", pass_context=True, name="remove_old_jobs")
@app.task(
lock="remove_old_jobs",
queueing_lock="remove_old_jobs",
pass_context=True,
name="remove_old_jobs",
)
async def remove_old_jobs(context, timestamp):
try:
return await builtin_tasks.remove_old_jobs(
@@ -36,7 +41,11 @@ async def remove_old_jobs(context, timestamp):
@app.periodic(cron="0 6 1 * *")
@app.task(queueing_lock="remove_expired_sessions", name="remove_expired_sessions")
@app.task(
lock="remove_expired_sessions",
queueing_lock="remove_expired_sessions",
name="remove_expired_sessions",
)
async def remove_expired_sessions(timestamp=None):
"""Cleanup expired sessions by using Django management command."""
try:
@@ -49,7 +58,7 @@ async def remove_expired_sessions(timestamp=None):
@app.periodic(cron="0 8 * * *")
@app.task(name="reset_demo_data")
@app.task(lock="reset_demo_data", name="reset_demo_data")
def reset_demo_data(timestamp=None):
"""
Wipes the database and loads fresh demo data if DEMO mode is active.
@@ -86,9 +95,7 @@ def reset_demo_data(timestamp=None):
@app.periodic(cron="0 */12 * * *") # Every 12 hours
@app.task(
name="check_for_updates",
)
@app.task(lock="check_for_updates", name="check_for_updates")
def check_for_updates(timestamp=None):
if not settings.CHECK_FOR_UPDATES:
return "CHECK_FOR_UPDATES is disabled"

View File

@@ -1,5 +1,4 @@
import logging
from datetime import timedelta
from django.db.models import QuerySet
from django.utils import timezone
@@ -258,7 +257,10 @@ class ExchangeRateFetcher:
processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now()
service.failure_count = 0
service.save()
except Exception as e:
logger.error(f"Error fetching rates for {service.name}: {e}")
service.failure_count += 1
service.save()

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.10 on 2026-01-10 06:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0022_currency_is_archived'),
]
operations = [
migrations.AddField(
model_name='exchangerateservice',
name='failure_count',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -136,6 +136,8 @@ class ExchangeRateService(models.Model):
null=True, blank=True, verbose_name=_("Last Successful Fetch")
)
failure_count = models.PositiveIntegerField(default=0)
target_currencies = models.ManyToManyField(
Currency,
verbose_name=_("Target Currencies"),
@@ -237,7 +239,7 @@ class ExchangeRateService(models.Model):
hours = self._parse_hour_ranges(self.fetch_interval)
# Store in normalized format (optional)
self.fetch_interval = ",".join(str(h) for h in sorted(hours))
except ValueError as e:
except ValueError:
raise ValidationError(
{
"fetch_interval": _(
@@ -248,7 +250,7 @@ class ExchangeRateService(models.Model):
)
except ValidationError:
raise
except Exception as e:
except Exception:
raise ValidationError(
{
"fetch_interval": _(

View File

@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 * * * *") # Run every hour
@app.task(name="automatic_fetch_exchange_rates")
@app.task(lock="automatic_fetch_exchange_rates", name="automatic_fetch_exchange_rates")
def automatic_fetch_exchange_rates(timestamp=None):
"""Fetch exchange rates for all due services"""
fetcher = ExchangeRateFetcher()
@@ -19,7 +19,7 @@ def automatic_fetch_exchange_rates(timestamp=None):
logger.error(e, exc_info=True)
@app.task(name="manual_fetch_exchange_rates")
@app.task(lock="manual_fetch_exchange_rates", name="manual_fetch_exchange_rates")
def manual_fetch_exchange_rates(timestamp=None):
"""Fetch exchange rates for all due services"""
fetcher = ExchangeRateFetcher()

View File

@@ -0,0 +1 @@
# Tests package for currencies app

View File

@@ -0,0 +1,109 @@
from decimal import Decimal
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.currencies.models import Currency, ExchangeRateService
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
class ExchangeRateServiceFailureTrackingTests(TestCase):
"""Tests for the failure count tracking functionality."""
def setUp(self):
"""Set up test data."""
self.usd = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.eur = Currency.objects.create(
code="EUR", name="Euro", decimal_places=2, prefix=""
)
self.eur.exchange_currency = self.usd
self.eur.save()
self.service = ExchangeRateService.objects.create(
name="Test Service",
service_type=ExchangeRateService.ServiceType.FRANKFURTER,
is_active=True,
)
self.service.target_currencies.add(self.eur)
def test_failure_count_increments_on_provider_error(self):
"""Test that failure_count increments when provider raises an exception."""
self.assertEqual(self.service.failure_count, 0)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 1)
def test_failure_count_resets_on_success(self):
"""Test that failure_count resets to 0 on successful fetch."""
# Set initial failure count
self.service.failure_count = 5
self.service.save()
# Mock a successful provider
mock_provider = MagicMock()
mock_provider.requires_api_key.return_value = False
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
mock_provider.rates_inverted = False
with patch.object(self.service, "get_provider", return_value=mock_provider):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 0)
def test_failure_count_accumulates_across_fetches(self):
"""Test that failure_count accumulates with consecutive failures."""
self.assertEqual(self.service.failure_count, 0)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 1)
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 2)
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertEqual(self.service.failure_count, 3)
def test_last_fetch_not_updated_on_failure(self):
"""Test that last_fetch is NOT updated when a failure occurs."""
original_last_fetch = self.service.last_fetch
self.assertIsNone(original_last_fetch)
with patch.object(
self.service, "get_provider", side_effect=Exception("API Error")
):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertIsNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 1)
def test_last_fetch_updated_on_success(self):
"""Test that last_fetch IS updated when fetch succeeds."""
self.assertIsNone(self.service.last_fetch)
mock_provider = MagicMock()
mock_provider.requires_api_key.return_value = False
mock_provider.get_rates.return_value = [(self.usd, self.eur, Decimal("0.85"))]
mock_provider.rates_inverted = False
with patch.object(self.service, "get_provider", return_value=mock_provider):
ExchangeRateFetcher._fetch_service_rates(self.service)
self.service.refresh_from_db()
self.assertIsNotNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 0)

View File

@@ -23,7 +23,7 @@ def currencies_index(request):
@login_required
@require_http_methods(["GET"])
def currencies_list(request):
currencies = Currency.objects.all().order_by("id")
currencies = Currency.objects.all().order_by("name")
return render(
request,
"currencies/fragments/list.html",

View File

@@ -1,4 +1,3 @@
# apps/dca_tracker/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Avg
@@ -23,7 +22,7 @@ def strategy_index(request):
@only_htmx
@login_required
def strategy_list(request):
strategies = DCAStrategy.objects.all().order_by("created_at")
strategies = DCAStrategy.objects.all().order_by("name")
return render(
request, "dca/fragments/strategy/list.html", {"strategies": strategies}
)
@@ -234,7 +233,7 @@ def strategy_entry_add(request, strategy_id):
if request.method == "POST":
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save()
form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(

View File

@@ -1,8 +1,10 @@
from import_export import fields, resources
from import_export.widgets import ForeignKeyWidget
from apps.accounts.models import Account
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.export_app.widgets.foreign_key import (
AllObjectsForeignKeyWidget,
AutoCreateForeignKeyWidget,
)
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
from apps.export_app.widgets.string import EmptyStringToNoneField
from apps.transactions.models import (
@@ -20,7 +22,7 @@ class TransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -86,7 +88,7 @@ class RecurringTransactionResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -119,12 +121,16 @@ class RecurringTransactionResource(resources.ModelResource):
def get_queryset(self):
return RecurringTransaction.all_objects.all()
def dehydrate_account_owner(self, obj):
"""Export the account's owner ID for proper import matching."""
return obj.account.owner_id if obj.account else None
class InstallmentPlanResource(resources.ModelResource):
account = fields.Field(
attribute="account",
column_name="account",
widget=ForeignKeyWidget(Account, "name"),
widget=AllObjectsForeignKeyWidget(Account, "name"),
)
category = fields.Field(
@@ -156,3 +162,7 @@ class InstallmentPlanResource(resources.ModelResource):
def get_queryset(self):
return InstallmentPlan.all_objects.all()
def dehydrate_account_owner(self, obj):
"""Export the account's owner ID for proper import matching."""
return obj.account.owner_id if obj.account else None

View File

@@ -1,6 +1,60 @@
from import_export.widgets import ForeignKeyWidget
class AllObjectsForeignKeyWidget(ForeignKeyWidget):
"""
ForeignKeyWidget that uses 'all_objects' manager for lookups,
bypassing user-filtered managers like SharedObjectManager.
Also filters by owner if available in the row data.
"""
def get_queryset(self, value, row, *args, **kwargs):
# Use all_objects manager if available, otherwise fall back to default
if hasattr(self.model, "all_objects"):
qs = self.model.all_objects.all()
# Filter by owner if the row has an owner field and the model has owner
if row:
# Check for direct owner field first
owner_id = row.get("owner") if "owner" in row else None
# Fall back to account_owner for models like InstallmentPlan
if not owner_id and "account_owner" in row:
owner_id = row.get("account_owner")
# If still no owner, try to get it from the existing record's account
# This handles backward compatibility with older exports
if not owner_id and "id" in row and row.get("id"):
try:
# Try to find the existing record and get owner from its account
from apps.transactions.models import (
InstallmentPlan,
RecurringTransaction,
)
record_id = row.get("id")
# Try to find the existing InstallmentPlan or RecurringTransaction
for model_class in [InstallmentPlan, RecurringTransaction]:
try:
existing = model_class.all_objects.get(id=record_id)
if existing.account:
owner_id = existing.account.owner_id
break
except model_class.DoesNotExist:
continue
except Exception:
pass
# Final fallback: use the current logged-in user
# This handles restoring to a fresh database with older exports
if not owner_id:
from apps.common.middleware.thread_local import get_current_user
user = get_current_user()
if user and user.is_authenticated:
owner_id = user.id
if owner_id:
qs = qs.filter(owner_id=owner_id)
return qs
return super().get_queryset(value, row, *args, **kwargs)
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
def clean(self, value, row=None, *args, **kwargs):
if value:

View File

@@ -459,12 +459,13 @@ class ImportService:
# Build query conditions for each field in the rule
for field in rule.fields:
if field in transaction_data:
if rule.match_type == "strict":
query = query.filter(**{field: transaction_data[field]})
else: # lax matching
query = query.filter(
**{f"{field}__iexact": transaction_data[field]}
)
value = transaction_data[field]
# Use __iexact only for string fields; non-string types
# (date, Decimal, bool, int, etc.) don't support UPPER()
if rule.match_type == "strict" or not isinstance(value, str):
query = query.filter(**{field: value})
else: # lax matching for strings only
query = query.filter(**{f"{field}__iexact": value})
# If we found any matching transaction, it's a duplicate
if query.exists():

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

View File

@@ -0,0 +1,275 @@
"""
Tests for ImportService v1, specifically for deduplication logic.
These tests verify that the _check_duplicate_transaction method handles
different field types correctly, particularly ensuring that __iexact
is only used for string fields (not dates, decimals, etc.).
"""
from datetime import date
from decimal import Decimal
from django.test import TestCase
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.services.v1 import ImportService
from apps.transactions.models import Transaction
class DeduplicationTests(TestCase):
"""Tests for transaction deduplication during import."""
def setUp(self):
"""Set up test data."""
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account", group=self.account_group, currency=self.currency
)
# Create an existing transaction for deduplication tests
self.existing_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=date(2024, 1, 15),
amount=Decimal("100.00"),
description="Existing Transaction",
internal_id="ABC123",
)
def _create_import_service_with_deduplication(
self, fields: list[str], match_type: str = "lax"
) -> ImportService:
"""Helper to create an ImportService with specific deduplication rules."""
yaml_config = f"""
settings:
file_type: csv
importing: transactions
trigger_transaction_rules: false
mapping:
date_field:
source: date
target: date
format: "%Y-%m-%d"
amount_field:
source: amount
target: amount
description_field:
source: description
target: description
account_field:
source: account
target: account
type: id
deduplication:
- type: compare
fields: {fields}
match_type: {match_type}
"""
profile = ImportProfile.objects.create(
name=f"Test Profile {match_type} {'_'.join(fields)}",
yaml_config=yaml_config,
version=ImportProfile.Versions.VERSION_1,
)
import_run = ImportRun.objects.create(
profile=profile,
file_name="test.csv",
)
return ImportService(import_run)
def test_deduplication_with_date_field_strict_match(self):
"""Test that date fields work with strict matching."""
service = self._create_import_service_with_deduplication(
fields=["date"], match_type="strict"
)
# Should find duplicate when date matches
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
self.assertTrue(is_duplicate)
# Should not find duplicate when date differs
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
self.assertFalse(is_duplicate)
def test_deduplication_with_date_field_lax_match(self):
"""
Test that date fields use strict matching even when match_type is 'lax'.
This is the fix for the UPPER(date) PostgreSQL error. Date fields
cannot use __iexact, so they should fall back to strict matching.
"""
service = self._create_import_service_with_deduplication(
fields=["date"], match_type="lax"
)
# Should find duplicate when date matches (using strict comparison)
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 1, 15)})
self.assertTrue(is_duplicate)
# Should not find duplicate when date differs
is_duplicate = service._check_duplicate_transaction({"date": date(2024, 2, 20)})
self.assertFalse(is_duplicate)
def test_deduplication_with_amount_field_lax_match(self):
"""
Test that Decimal fields use strict matching even when match_type is 'lax'.
Decimal fields cannot use __iexact, so they should fall back to strict matching.
"""
service = self._create_import_service_with_deduplication(
fields=["amount"], match_type="lax"
)
# Should find duplicate when amount matches
is_duplicate = service._check_duplicate_transaction(
{"amount": Decimal("100.00")}
)
self.assertTrue(is_duplicate)
# Should not find duplicate when amount differs
is_duplicate = service._check_duplicate_transaction(
{"amount": Decimal("200.00")}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_string_field_lax_match(self):
"""
Test that string fields use case-insensitive matching with match_type 'lax'.
"""
service = self._create_import_service_with_deduplication(
fields=["description"], match_type="lax"
)
# Should find duplicate with case-insensitive match
is_duplicate = service._check_duplicate_transaction(
{"description": "EXISTING TRANSACTION"}
)
self.assertTrue(is_duplicate)
# Should find duplicate with exact case match
is_duplicate = service._check_duplicate_transaction(
{"description": "Existing Transaction"}
)
self.assertTrue(is_duplicate)
# Should not find duplicate when description differs
is_duplicate = service._check_duplicate_transaction(
{"description": "Different Transaction"}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_string_field_strict_match(self):
"""
Test that string fields use case-sensitive matching with match_type 'strict'.
"""
service = self._create_import_service_with_deduplication(
fields=["description"], match_type="strict"
)
# Should NOT find duplicate with different case (strict matching)
is_duplicate = service._check_duplicate_transaction(
{"description": "EXISTING TRANSACTION"}
)
self.assertFalse(is_duplicate)
# Should find duplicate with exact case match
is_duplicate = service._check_duplicate_transaction(
{"description": "Existing Transaction"}
)
self.assertTrue(is_duplicate)
def test_deduplication_with_multiple_fields_mixed_types(self):
"""
Test deduplication with multiple fields of different types.
Verifies that string fields use __iexact while non-string fields
use strict matching, all in the same deduplication rule.
"""
service = self._create_import_service_with_deduplication(
fields=["date", "amount", "description"], match_type="lax"
)
# Should find duplicate when all fields match (with case-insensitive description)
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("100.00"),
"description": "existing transaction", # lowercase should match
}
)
self.assertTrue(is_duplicate)
# Should NOT find duplicate when date differs
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 2, 20),
"amount": Decimal("100.00"),
"description": "existing transaction",
}
)
self.assertFalse(is_duplicate)
# Should NOT find duplicate when amount differs
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("999.99"),
"description": "existing transaction",
}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_internal_id_lax_match(self):
"""Test deduplication with internal_id field using lax matching."""
service = self._create_import_service_with_deduplication(
fields=["internal_id"], match_type="lax"
)
# Should find duplicate with case-insensitive match
is_duplicate = service._check_duplicate_transaction(
{"internal_id": "abc123"} # lowercase should match ABC123
)
self.assertTrue(is_duplicate)
# Should find duplicate with exact match
is_duplicate = service._check_duplicate_transaction({"internal_id": "ABC123"})
self.assertTrue(is_duplicate)
# Should not find duplicate when internal_id differs
is_duplicate = service._check_duplicate_transaction({"internal_id": "XYZ789"})
self.assertFalse(is_duplicate)
def test_no_duplicate_when_no_transactions_exist(self):
"""Test that no duplicate is found when there are no matching transactions."""
# Hard delete to bypass signals that require user context
self.existing_transaction.hard_delete()
service = self._create_import_service_with_deduplication(
fields=["date", "amount"], match_type="lax"
)
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
"amount": Decimal("100.00"),
}
)
self.assertFalse(is_duplicate)
def test_deduplication_with_missing_field_in_data(self):
"""Test that missing fields in transaction_data are handled gracefully."""
service = self._create_import_service_with_deduplication(
fields=["date", "nonexistent_field"], match_type="lax"
)
# Should still work, only checking the fields that exist
is_duplicate = service._check_duplicate_transaction(
{
"date": date(2024, 1, 15),
}
)
self.assertTrue(is_duplicate)

View File

@@ -49,4 +49,14 @@ urlpatterns = [
views.emergency_fund,
name="insights_emergency_fund",
),
path(
"insights/year-by-year/",
views.year_by_year,
name="insights_year_by_year",
),
path(
"insights/month-by-month/",
views.month_by_month,
name="insights_month_by_month",
),
]

View File

@@ -0,0 +1,316 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_month_by_month_data(year=None, group_by="categories"):
"""
Aggregate transaction totals by month for a specific year, grouped by categories, tags, or entities.
Args:
year: The year to filter transactions (defaults to current year)
group_by: One of "categories", "tags", or "entities"
Returns:
{
"year": 2025,
"available_years": [2025, 2024, ...],
"months": [1, 2, 3, ..., 12],
"items": {
item_id: {
"name": "Item Name",
"month_totals": {
1: {"currencies": {...}},
...
},
"total": {"currencies": {...}}
},
...
},
"month_totals": {...},
"grand_total": {"currencies": {...}}
}
"""
if year is None:
year = timezone.localdate(timezone.now()).year
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Get available years for the selector
available_years = list(
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
# Filter by the selected year
transactions = transactions.filter(reference_date__year=year)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Months 1-12
months = list(range(1, 13))
if not available_years:
return {
"year": year,
"available_years": [],
"months": months,
"items": {},
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, month, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__month",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "reference_date__month")
)
# Build result structure
result = {
"year": year,
"available_years": available_years,
"months": months,
"items": OrderedDict(),
"month_totals": {},
"grand_total": {"currencies": {}},
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
month = metric["reference_date__month"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"month_totals": {},
"total": {"currencies": {}},
}
if month not in result["items"][item_key]["month_totals"]:
result["items"][item_key]["month_totals"][month] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["month_totals"][month]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all months for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate month total (across all items for this month)
if month not in result["month_totals"]:
result["month_totals"][month] = {"currencies": {}}
if currency_id not in result["month_totals"][month]["currencies"]:
result["month_totals"][month]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["month_totals"][month]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for month totals
for month, month_data in result["month_totals"].items():
for currency_id, total_data in month_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result

View File

@@ -0,0 +1,303 @@
from collections import OrderedDict
from decimal import Decimal
from django.db import models
from django.db.models import Sum, Case, When, Value
from django.db.models.functions import Coalesce
from apps.currencies.models import Currency
from apps.currencies.utils.convert import convert
from apps.transactions.models import Transaction
def get_year_by_year_data(group_by="categories"):
"""
Aggregate transaction totals by year for categories, tags, or entities.
Args:
group_by: One of "categories", "tags", or "entities"
Returns:
{
"years": [2025, 2024, ...], # Sorted descending
"items": {
item_id: {
"name": "Item Name",
"year_totals": {
2025: {"currencies": {...}},
...
},
"total": {"currencies": {...}} # Sum across all years
},
...
},
"year_totals": { # Sum across all items for each year
2025: {"currencies": {...}},
...
},
"grand_total": {"currencies": {...}} # Sum of everything
}
"""
# Base queryset - all paid transactions, non-muted
transactions = Transaction.objects.filter(
is_paid=True,
account__is_archived=False,
).exclude(account__currency__is_archived=True)
# Define grouping fields based on group_by parameter
if group_by == "tags":
group_field = "tags"
name_field = "tags__name"
elif group_by == "entities":
group_field = "entities"
name_field = "entities__name"
else: # Default to categories
group_field = "category"
name_field = "category__name"
# Get all unique years with transactions
years = (
transactions.values_list("reference_date__year", flat=True)
.distinct()
.order_by("-reference_date__year")
)
years = list(years)
if not years:
return {
"years": [],
"items": {},
"year_totals": {},
"grand_total": {"currencies": {}},
}
# Aggregate by group, year, and currency
metrics = (
transactions.values(
group_field,
name_field,
"reference_date__year",
"account__currency",
"account__currency__code",
"account__currency__name",
"account__currency__decimal_places",
"account__currency__prefix",
"account__currency__suffix",
"account__currency__exchange_currency",
)
.annotate(
expense_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.EXPENSE, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
income_total=Coalesce(
Sum(
Case(
When(type=Transaction.Type.INCOME, then="amount"),
default=Value(0),
output_field=models.DecimalField(),
)
),
Decimal("0"),
),
)
.order_by(name_field, "-reference_date__year")
)
# Build result structure
result = {
"years": years,
"items": OrderedDict(),
"year_totals": {}, # Totals per year across all items
"grand_total": {"currencies": {}}, # Grand total across everything
}
# Store currency info for later use in totals
currency_info = {}
for metric in metrics:
item_id = metric[group_field]
item_name = metric[name_field]
year = metric["reference_date__year"]
currency_id = metric["account__currency"]
# Use a consistent key for None (uncategorized/untagged/no entity)
item_key = item_id if item_id is not None else "__none__"
if item_key not in result["items"]:
result["items"][item_key] = {
"name": item_name,
"year_totals": {},
"total": {"currencies": {}}, # Total for this item across all years
}
if year not in result["items"][item_key]["year_totals"]:
result["items"][item_key]["year_totals"][year] = {"currencies": {}}
# Calculate final total (income - expense)
final_total = metric["income_total"] - metric["expense_total"]
# Store currency info for totals calculation
if currency_id not in currency_info:
currency_info[currency_id] = {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
"exchange_currency_id": metric["account__currency__exchange_currency"],
}
currency_data = {
"currency": {
"code": metric["account__currency__code"],
"name": metric["account__currency__name"],
"decimal_places": metric["account__currency__decimal_places"],
"prefix": metric["account__currency__prefix"],
"suffix": metric["account__currency__suffix"],
},
"final_total": final_total,
"income_total": metric["income_total"],
"expense_total": metric["expense_total"],
}
# Handle currency conversion if exchange currency is set
if metric["account__currency__exchange_currency"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=metric["account__currency__exchange_currency"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=final_total,
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
currency_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
result["items"][item_key]["year_totals"][year]["currencies"][currency_id] = (
currency_data
)
# Accumulate item total (across all years for this item)
if currency_id not in result["items"][item_key]["total"]["currencies"]:
result["items"][item_key]["total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["items"][item_key]["total"]["currencies"][currency_id][
"final_total"
] += final_total
# Accumulate year total (across all items for this year)
if year not in result["year_totals"]:
result["year_totals"][year] = {"currencies": {}}
if currency_id not in result["year_totals"][year]["currencies"]:
result["year_totals"][year]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["year_totals"][year]["currencies"][currency_id]["final_total"] += (
final_total
)
# Accumulate grand total
if currency_id not in result["grand_total"]["currencies"]:
result["grand_total"]["currencies"][currency_id] = {
"currency": currency_data["currency"].copy(),
"final_total": Decimal("0"),
}
result["grand_total"]["currencies"][currency_id]["final_total"] += final_total
# Add currency conversion for item totals
for item_key, item_data in result["items"].items():
for currency_id, total_data in item_data["total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for year totals
for year, year_data in result["year_totals"].items():
for currency_id, total_data in year_data["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
# Add currency conversion for grand total
for currency_id, total_data in result["grand_total"]["currencies"].items():
if currency_info[currency_id]["exchange_currency_id"]:
from_currency = Currency.objects.get(id=currency_id)
exchange_currency = Currency.objects.get(
id=currency_info[currency_id]["exchange_currency_id"]
)
converted_amount, prefix, suffix, decimal_places = convert(
amount=total_data["final_total"],
from_currency=from_currency,
to_currency=exchange_currency,
)
if converted_amount is not None:
total_data["exchanged"] = {
"final_total": converted_amount,
"currency": {
"prefix": prefix,
"suffix": suffix,
"decimal_places": decimal_places,
"code": exchange_currency.code,
"name": exchange_currency.name,
},
}
return result

View File

@@ -26,6 +26,8 @@ from apps.insights.utils.sankey import (
generate_sankey_data_by_currency,
)
from apps.insights.utils.transactions import get_transactions
from apps.insights.utils.year_by_year import get_year_by_year_data
from apps.insights.utils.month_by_month import get_month_by_month_data
from apps.transactions.models import TransactionCategory, Transaction
from apps.transactions.utils.calculations import calculate_currency_totals
@@ -74,7 +76,9 @@ def index(request):
def sankey_by_account(request):
# Get filtered transactions
transactions = get_transactions(request, include_untracked_accounts=True)
transactions = get_transactions(
request, include_untracked_accounts=True, include_silent=True
)
# Generate Sankey data
sankey_data = generate_sankey_data_by_account(transactions)
@@ -91,7 +95,9 @@ def sankey_by_account(request):
@require_http_methods(["GET"])
def sankey_by_currency(request):
# Get filtered transactions
transactions = get_transactions(request)
transactions = get_transactions(
request, include_silent=True, include_untracked_accounts=True
)
# Generate Sankey data
sankey_data = generate_sankey_data_by_currency(transactions)
@@ -302,3 +308,71 @@ def emergency_fund(request):
"insights/fragments/emergency_fund.html",
{"data": currency_net_worth},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def year_by_year(request):
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_year_by_year_group_by"] = group_by
else:
group_by = request.session.get("insights_year_by_year_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_year_by_year_data(group_by=group_by)
return render(
request,
"insights/fragments/year_by_year.html",
{
"data": data,
"group_by": group_by,
},
)
@only_htmx
@login_required
@require_http_methods(["GET"])
def month_by_month(request):
# Handle year selection
if "year" in request.GET:
try:
year = int(request.GET["year"])
request.session["insights_month_by_month_year"] = year
except (ValueError, TypeError):
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
else:
year = request.session.get(
"insights_month_by_month_year", timezone.localdate(timezone.now()).year
)
# Handle group_by selection
if "group_by" in request.GET:
group_by = request.GET["group_by"]
request.session["insights_month_by_month_group_by"] = group_by
else:
group_by = request.session.get("insights_month_by_month_group_by", "categories")
# Validate group_by value
if group_by not in ("categories", "tags", "entities"):
group_by = "categories"
data = get_month_by_month_data(year=year, group_by=group_by)
return render(
request,
"insights/fragments/month_by_month.html",
{
"data": data,
"group_by": group_by,
"selected_year": year,
},
)

View File

@@ -0,0 +1,331 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionTag,
)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
)
class MonthlySummaryFilterBehaviorTests(TestCase):
"""Tests for monthly summary views filter behavior.
These tests verify that:
1. Views work correctly without any filters
2. Views work correctly with filters applied
3. The filter detection logic properly uses different querysets
4. Calculated values reflect the applied filters
"""
def setUp(self):
"""Set up test data"""
User = get_user_model()
self.user = User.objects.create_user(
email="testuser@test.com", password="testpass123"
)
self.client.login(username="testuser@test.com", password="testpass123")
self.currency = Currency.objects.create(
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
)
self.account_group = AccountGroup.objects.create(name="Test Group")
self.account = Account.objects.create(
name="Test Account",
group=self.account_group,
currency=self.currency,
is_asset=False,
)
self.category = TransactionCategory.objects.create(
name="Test Category", owner=self.user
)
self.tag = TransactionTag.objects.create(name="TestTag", owner=self.user)
# Create test transactions for December 2025
# Income: 1000 (paid)
self.income_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.INCOME,
is_paid=True,
date=date(2025, 12, 10),
reference_date=date(2025, 12, 1),
amount=Decimal("1000.00"),
description="December Income",
owner=self.user,
)
# Expense: 200 (paid)
self.expense_transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
is_paid=True,
date=date(2025, 12, 15),
reference_date=date(2025, 12, 1),
amount=Decimal("200.00"),
description="December Expense",
category=self.category,
owner=self.user,
)
self.expense_transaction.tags.add(self.tag)
# Expense: 150 (projected/unpaid)
self.projected_expense = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
is_paid=False,
date=date(2025, 12, 20),
reference_date=date(2025, 12, 1),
amount=Decimal("150.00"),
description="Projected Expense",
owner=self.user,
)
def _get_currency_data(self, context_dict):
"""Helper to extract data for our test currency from context dict.
The context dict is keyed by currency ID, so we need to find
the entry for our currency.
"""
if not context_dict:
return None
for currency_id, data in context_dict.items():
if data.get("currency", {}).get("code") == "USD":
return data
return None
# --- monthly_summary view tests ---
def test_monthly_summary_no_filter_returns_200(self):
"""Test that monthly_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_summary_no_filter_includes_all_transactions(self):
"""Without filters, summary should include all transactions"""
response = self.client.get(
"/monthly/12/2025/summary/",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should have the income: 1000
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should have paid expense: 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have unpaid expense: 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_type_filter_only_income(self):
"""With type=IN filter, summary should only include income"""
response = self.client.get(
"/monthly/12/2025/summary/?type=IN",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should still have 1000
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should be empty/zero (filtered out)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
# expense_projected should be empty/zero (filtered out)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
if usd_data:
self.assertEqual(usd_data.get("expense_projected", 0), Decimal("0"))
def test_monthly_summary_type_filter_only_expenses(self):
"""With type=EX filter, summary should only include expenses"""
response = self.client.get(
"/monthly/12/2025/summary/?type=EX",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should be empty/zero (filtered out)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should have 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_is_paid_filter_only_paid(self):
"""With is_paid=1 filter, summary should only include paid transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?is_paid=1",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should have 1000 (paid)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# expense_current should have 200 (paid)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should be empty/zero (filtered out - unpaid)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
if usd_data:
self.assertEqual(usd_data.get("expense_projected", 0), Decimal("0"))
def test_monthly_summary_is_paid_filter_only_unpaid(self):
"""With is_paid=0 filter, summary should only include unpaid transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?is_paid=0",
HTTP_HX_REQUEST="true",
)
context = response.context
# income_current should be empty/zero (filtered out - paid)
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should be empty/zero (filtered out - paid)
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
# expense_projected should have 150 (unpaid)
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
def test_monthly_summary_description_filter(self):
"""With description filter, summary should only include matching transactions"""
response = self.client.get(
"/monthly/12/2025/summary/?description=Income",
HTTP_HX_REQUEST="true",
)
context = response.context
# Only income matches "Income" description
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["income_current"], Decimal("1000.00"))
# Expenses should be filtered out
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
if usd_data:
self.assertEqual(usd_data.get("expense_current", 0), Decimal("0"))
def test_monthly_summary_amount_filter(self):
"""With amount filter, summary should only include transactions in range"""
# Filter to only get transactions between 100 and 250 (should get 200 and 150)
response = self.client.get(
"/monthly/12/2025/summary/?from_amount=100&to_amount=250",
HTTP_HX_REQUEST="true",
)
context = response.context
# Income (1000) should be filtered out
income_current = context.get("income_current", {})
usd_data = self._get_currency_data(income_current)
if usd_data:
self.assertEqual(usd_data.get("income_current", 0), Decimal("0"))
# expense_current should have 200
expense_current = context.get("expense_current", {})
usd_data = self._get_currency_data(expense_current)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_current"], Decimal("200.00"))
# expense_projected should have 150
expense_projected = context.get("expense_projected", {})
usd_data = self._get_currency_data(expense_projected)
self.assertIsNotNone(usd_data)
self.assertEqual(usd_data["expense_projected"], Decimal("150.00"))
# --- monthly_account_summary view tests ---
def test_monthly_account_summary_no_filter_returns_200(self):
"""Test that monthly_account_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/accounts/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_account_summary_with_filter_returns_200(self):
"""Test that monthly_account_summary returns 200 with filter"""
response = self.client.get(
"/monthly/12/2025/summary/accounts/?type=IN",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
# --- monthly_currency_summary view tests ---
def test_monthly_currency_summary_no_filter_returns_200(self):
"""Test that monthly_currency_summary returns 200 without filters"""
response = self.client.get(
"/monthly/12/2025/summary/currencies/",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)
def test_monthly_currency_summary_with_filter_returns_200(self):
"""Test that monthly_currency_summary returns 200 with filter"""
response = self.client.get(
"/monthly/12/2025/summary/currencies/?type=EX",
HTTP_HX_REQUEST="true",
)
self.assertEqual(response.status_code, 200)

View File

@@ -2,7 +2,8 @@ from django.contrib.auth.decorators import login_required
from django.db.models import (
Q,
)
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_http_methods
@@ -36,8 +37,6 @@ def monthly_overview(request, month: int, year: int):
summary_tab = request.session.get("monthly_summary_tab", "summary")
if month < 1 or month > 12:
from django.http import Http404
raise Http404("Month is out of range")
next_month = 1 if month == 12 else month + 1
@@ -76,6 +75,8 @@ def transactions_list(request, month: int, year: int):
if order != request.session.get("monthly_transactions_order", "default"):
request.session["monthly_transactions_order"] = order
today = timezone.localdate(timezone.now())
f = TransactionsFilter(request.GET)
transactions_filtered = f.qs.filter(
reference_date__year=year,
@@ -93,12 +94,28 @@ def transactions_list(request, month: int, year: int):
"dca_income_entries",
)
# Late transactions: date < today and is_paid = False (only shown for default ordering)
late_transactions = None
if order == "default":
late_transactions = transactions_filtered.filter(
date__lt=today,
is_paid=False,
).order_by("date", "id")
# Exclude late transactions from the main list
transactions_filtered = transactions_filtered.exclude(
date__lt=today,
is_paid=False,
)
transactions_filtered = default_order(transactions_filtered, order=order)
return render(
request,
"monthly_overview/fragments/list.html",
context={"transactions": transactions_filtered},
context={
"transactions": transactions_filtered,
"late_transactions": late_transactions,
},
)
@@ -107,17 +124,48 @@ def transactions_list(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
account__is_asset=False,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
data = calculate_currency_totals(base_queryset, ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
# Default values are: type=['IN', 'EX'], is_paid=['1', '0'], everything else empty
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
# Skip fields with default/empty values
if not value:
continue
# Skip type if it has both default values
if name == "type" and set(value) == {"IN", "EX"}:
continue
# Skip is_paid if it has both default values (values are strings)
if name == "is_paid" and set(value) == {"1", "0"}:
continue
# Skip mute_status if it has both default values
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
# If we get here, there's an active filter
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
data = calculate_currency_totals(queryset, ignore_empty=True)
percentages = calculate_percentage_distribution(data)
context = {
@@ -132,6 +180,7 @@ def monthly_summary(request, month: int, year: int):
currency_totals=data, month=month, year=year
),
"percentages": percentages,
"has_active_filter": has_active_filter,
}
return render(
@@ -149,9 +198,38 @@ def monthly_account_summary(request, month: int, year: int):
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
)
account_data = calculate_account_totals(transactions_queryset=base_queryset.all())
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
account_data = calculate_account_totals(transactions_queryset=queryset.all())
account_percentages = calculate_percentage_distribution(account_data)
context = {
@@ -171,16 +249,41 @@ def monthly_account_summary(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_currency_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
currency_data = calculate_currency_totals(queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)
context = {

View File

@@ -1,3 +1,4 @@
from crispy_forms.bootstrap import Alert
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
@@ -36,7 +37,6 @@ class TransactionRuleForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
# TO-DO: Add helper with available commands
self.helper.layout = Layout(
Switch("active"),
"name",
@@ -49,6 +49,9 @@ class TransactionRuleForm(forms.ModelForm):
Switch("sequenced"),
"description",
"trigger",
Alert(
_("You can add actions to this rule in the next screen."), dismiss=False
),
)
if self.instance and self.instance.pk:

View File

@@ -23,6 +23,11 @@ SITUACAO_CHOICES = (
("0", _("Projected")),
)
MUTE_STATUS_CHOICES = (
("active", _("Active")),
("muted", _("Muted")),
)
def content_filter(queryset, name, value):
queryset = queryset.filter(
@@ -78,6 +83,11 @@ class TransactionsFilter(django_filters.FilterSet):
choices=SITUACAO_CHOICES,
field_name="is_paid",
)
mute_status = django_filters.MultipleChoiceFilter(
choices=MUTE_STATUS_CHOICES,
method="filter_mute_status",
label=_("Mute Status"),
)
date_start = django_filters.DateFilter(
field_name="date",
lookup_expr="gte",
@@ -140,6 +150,9 @@ class TransactionsFilter(django_filters.FilterSet):
if data.get("is_paid") is None:
data.setlist("is_paid", ["1", "0"])
if data.get("mute_status") is None:
data.setlist("mute_status", ["active", "muted"])
super().__init__(data, *args, **kwargs)
self.form.helper = FormHelper()
@@ -155,6 +168,10 @@ class TransactionsFilter(django_filters.FilterSet):
"is_paid",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field(
"mute_status",
template="transactions/widgets/transaction_type_filter_buttons.html",
),
Field("description"),
Row(Column("date_start"), Column("date_end")),
Row(
@@ -268,3 +285,36 @@ class TransactionsFilter(django_filters.FilterSet):
return queryset.filter(q).distinct()
return queryset
@staticmethod
def filter_mute_status(queryset, name, value):
from apps.common.middleware.thread_local import get_current_user
if not value:
return queryset
value = list(value)
# If both are selected, return all
if "active" in value and "muted" in value:
return queryset
user = get_current_user()
# Only Active selected: exclude muted transactions
if "active" in value:
return (
queryset.exclude(account__untracked_by=user)
.filter(
mute=False,
)
.filter(Q(category__mute=False) | Q(category__isnull=True))
)
# Only Muted selected: include only muted transactions
if "muted" in value:
return queryset.filter(
Q(account__untracked_by=user) | Q(category__mute=True) | Q(mute=True)
)
return queryset

View File

@@ -20,7 +20,6 @@ from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import Q
from django.dispatch import Signal
from django.forms import ValidationError
from django.template.defaultfilters import date
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -384,6 +383,10 @@ class Transaction(OwnedObject):
def clean(self):
super().clean()
# Convert empty internal_id to None to allow multiple "empty" values with unique constraint
if self.internal_id == "":
self.internal_id = None
# Only process amount and reference_date if account exists
# If account is missing, Django's required field validation will handle it
try:
@@ -871,10 +874,8 @@ class RecurringTransaction(models.Model):
notes=self.notes if self.add_notes_to_transaction else "",
owner=self.account.owner,
)
if self.tags.exists():
created_transaction.tags.set(self.tags.all())
if self.entities.exists():
created_transaction.entities.set(self.entities.all())
created_transaction.tags.set(self.tags.all())
created_transaction.entities.set(self.entities.all())
def get_recurrence_delta(self):
if self.recurrence_type == self.RecurrenceType.DAY:

View File

@@ -13,7 +13,9 @@ logger = logging.getLogger(__name__)
@app.periodic(cron="0 0 * * *")
@app.task(name="generate_recurring_transactions")
@app.task(
lock="generate_recurring_transactions", name="generate_recurring_transactions"
)
def generate_recurring_transactions(timestamp=None):
try:
RecurringTransaction.generate_upcoming_transactions()
@@ -26,7 +28,7 @@ def generate_recurring_transactions(timestamp=None):
@app.periodic(cron="10 1 * * *")
@app.task(name="cleanup_deleted_transactions")
@app.task(lock="cleanup_deleted_transactions", name="cleanup_deleted_transactions")
def cleanup_deleted_transactions(timestamp=None):
if settings.ENABLE_SOFT_DELETE and settings.KEEP_DELETED_TRANSACTIONS_FOR == 0:
return "KEEP_DELETED_TRANSACTIONS_FOR is 0, no cleanup performed."

View File

@@ -125,6 +125,70 @@ class TransactionTests(TestCase):
datetime.datetime(day=1, month=2, year=2000).date(),
)
def test_empty_internal_id_converts_to_none(self):
"""Test that empty string internal_id is converted to None"""
transaction = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction",
internal_id="", # Empty string should become None
)
self.assertIsNone(transaction.internal_id)
def test_unique_internal_id_works(self):
"""Test that unique non-empty internal_id values work correctly"""
transaction1 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 1",
internal_id="unique-id-123",
)
transaction2 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 2",
internal_id="unique-id-456",
)
self.assertEqual(transaction1.internal_id, "unique-id-123")
self.assertEqual(transaction2.internal_id, "unique-id-456")
def test_multiple_transactions_with_empty_internal_id(self):
"""Test that multiple transactions can have empty/None internal_id"""
transaction1 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 1",
internal_id="",
)
transaction2 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 2",
internal_id="",
)
transaction3 = Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=timezone.now().date(),
amount=Decimal("100.00"),
description="Test transaction 3",
internal_id=None,
)
# All should be saved successfully with None internal_id
self.assertIsNone(transaction1.internal_id)
self.assertIsNone(transaction2.internal_id)
self.assertIsNone(transaction3.internal_id)
class InstallmentPlanTests(TestCase):
def setUp(self):

View File

@@ -35,7 +35,7 @@ def categories_list(request):
@login_required
@require_http_methods(["GET"])
def categories_table_active(request):
categories = TransactionCategory.objects.filter(active=True).order_by("id")
categories = TransactionCategory.objects.filter(active=True).order_by("name")
return render(
request,
"categories/fragments/table.html",
@@ -47,7 +47,7 @@ def categories_table_active(request):
@login_required
@require_http_methods(["GET"])
def categories_table_archived(request):
categories = TransactionCategory.objects.filter(active=False).order_by("id")
categories = TransactionCategory.objects.filter(active=False).order_by("name")
return render(
request,
"categories/fragments/table.html",

View File

@@ -35,7 +35,7 @@ def entities_list(request):
@login_required
@require_http_methods(["GET"])
def entities_table_active(request):
entities = TransactionEntity.objects.filter(active=True).order_by("id")
entities = TransactionEntity.objects.filter(active=True).order_by("name")
return render(
request,
"entities/fragments/table.html",
@@ -47,7 +47,7 @@ def entities_table_active(request):
@login_required
@require_http_methods(["GET"])
def entities_table_archived(request):
entities = TransactionEntity.objects.filter(active=False).order_by("id")
entities = TransactionEntity.objects.filter(active=False).order_by("name")
return render(
request,
"entities/fragments/table.html",

View File

@@ -35,7 +35,7 @@ def tags_list(request):
@login_required
@require_http_methods(["GET"])
def tags_table_active(request):
tags = TransactionTag.objects.filter(active=True).order_by("id")
tags = TransactionTag.objects.filter(active=True).order_by("name")
return render(
request,
"tags/fragments/table.html",
@@ -47,7 +47,7 @@ def tags_table_active(request):
@login_required
@require_http_methods(["GET"])
def tags_table_archived(request):
tags = TransactionTag.objects.filter(active=False).order_by("id")
tags = TransactionTag.objects.filter(active=False).order_by("name")
return render(
request,
"tags/fragments/table.html",

View File

@@ -152,7 +152,9 @@ def transaction_simple_add(request):
date_param = request.GET.get("date")
if date_param:
try:
initial_data["date"] = datetime.datetime.strptime(date_param, "%Y-%m-%d").date()
initial_data["date"] = datetime.datetime.strptime(
date_param, "%Y-%m-%d"
).date()
except ValueError:
pass
@@ -160,7 +162,9 @@ def transaction_simple_add(request):
reference_date_param = request.GET.get("reference_date")
if reference_date_param:
try:
initial_data["reference_date"] = datetime.datetime.strptime(reference_date_param, "%Y-%m-%d").date()
initial_data["reference_date"] = datetime.datetime.strptime(
reference_date_param, "%Y-%m-%d"
).date()
except ValueError:
pass
@@ -172,7 +176,10 @@ def transaction_simple_add(request):
except (ValueError, TypeError):
# Try to find by name
from apps.accounts.models import Account
account = Account.objects.filter(name__iexact=account_param, is_archived=False).first()
account = Account.objects.filter(
name__iexact=account_param, is_archived=False
).first()
if account:
initial_data["account"] = account.pk
@@ -207,7 +214,10 @@ def transaction_simple_add(request):
except (ValueError, TypeError):
# Try to find by name
from apps.transactions.models import TransactionCategory
category = TransactionCategory.objects.filter(name__iexact=category_param, active=True).first()
category = TransactionCategory.objects.filter(
name__iexact=category_param, active=True
).first()
if category:
initial_data["category"] = category.pk
@@ -457,7 +467,7 @@ def transaction_pay(request, transaction_id):
context={"transaction": transaction, **request.GET},
)
response.headers["HX-Trigger"] = (
f'{"paid" if new_is_paid else "unpaid"}, selective_update'
f"{'paid' if new_is_paid else 'unpaid'}, selective_update"
)
return response
@@ -552,6 +562,8 @@ def transaction_all_list(request):
if order != request.session.get("all_transactions_order", "default"):
request.session["all_transactions_order"] = order
today = timezone.localdate(timezone.now())
transactions = Transaction.objects.prefetch_related(
"account",
"account__group",
@@ -565,12 +577,27 @@ def transaction_all_list(request):
"dca_income_entries",
).all()
transactions = default_order(transactions, order=order)
f = TransactionsFilter(request.GET, queryset=transactions)
# Late transactions: date < today and is_paid = False (only shown for default ordering on first page)
late_transactions = None
page_number = request.GET.get("page", 1)
paginator = Paginator(f.qs, 100)
if order == "default" and str(page_number) == "1":
late_transactions = f.qs.filter(
date__lt=today,
is_paid=False,
).order_by("date", "id")
# Exclude late transactions from the main paginated list
main_transactions = f.qs.exclude(
date__lt=today,
is_paid=False,
)
else:
main_transactions = f.qs
main_transactions = default_order(main_transactions, order=order)
paginator = Paginator(main_transactions, 100)
page_obj = paginator.get_page(page_number)
return render(
@@ -579,6 +606,7 @@ def transaction_all_list(request):
{
"page_obj": page_obj,
"paginator": paginator,
"late_transactions": late_transactions,
},
)

View File

@@ -137,13 +137,13 @@ class UserSettingsForm(forms.ModelForm):
self.helper.layout = Layout(
"language",
"timezone",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
"date_format",
"datetime_format",
"number_format",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
"start_page",
HTML("<hr />"),
HTML('<hr class="hr my-3" />'),
"volume",
FormActions(
NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"),

View File

@@ -0,0 +1,39 @@
{% load active_link %}
{% load i18n %}
<c-vars id="collapsible-panel" />
<li>
<div role="button"
_="on click toggle .hidden on #{{ id }} then toggle .slide-in-left on #{{ id }}"
class="text-xs flex items-center no-underline ps-3 p-2 rounded-box sidebar-item cursor-pointer {% active_link views=active css_class='sidebar-active' %}">
<i class="{{ icon }} fa-fw"></i>
<span class="ml-3 font-medium lg:group-hover:truncate lg:group-focus:truncate lg:group-hover:text-ellipsis lg:group-focus:text-ellipsis">
{{ title }}
</span>
<i class="fa-solid fa-chevron-right fa-fw ml-auto pe-2"></i>
</div>
</li>
<div id="{{ id }}"
class="p-0 absolute bottom-0 left-0 w-full z-30 max-h-dvh {% active_link views=active css_class='slide-in-left' inactive_class='hidden' %}">
<div class="h-dvh bg-base-300 flex flex-col">
<div class="items-center p-4 border-b border-base-content/10 sidebar-submenu-header text-base-content">
<div class="flex items-center sidebar-submenu-title">
<i class="{{ icon }} fa-fw lg:group-hover:me-2 me-2 lg:me-0"></i>
<h5 class="text-lg font-semibold text-base-content m-0">
{{ title }}
</h5>
</div>
<button type="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{% trans 'Close' %}"
_="on click remove .slide-in-left from #{{ id }} then add .slide-out-left to #{{ id }} then wait 150ms then add .hidden to #{{ id }} then remove .slide-out-left from #{{ id }}">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<ul class="sidebar-item-list list-none p-3 flex flex-col gap-1 whitespace-nowrap lg:group-hover:animate-[disable-pointer-events] overflow-y-auto lg:overflow-y-hidden lg:hover:overflow-y-auto overflow-x-hidden"
style="animation-duration: 100ms">
{{ slot }}
</ul>
</div>
</div>

View File

@@ -1,12 +1,14 @@
<li class="lg:hidden lg:group-hover:block">
<div class="flex items-center" data-bs-toggle="collapse" href="#{{ title|slugify }}" role="button"
aria-expanded="false" aria-controls="{{ title|slugify }}">
<li class="lg:hidden lg:group-hover:block" x-data="{ open: false }">
<div class="flex items-center" @click="open = !open" role="button"
:aria-expanded="open">
<span
class="text-base-content/60 text-sm font-bold uppercase lg:hidden lg:group-hover:inline me-2">{{ title }}</span>
<hr class="flex-grow"/>
<i class="fas fa-chevron-down text-base-content/60 lg:before:hidden lg:group-hover:before:inline ml-2 lg:ml-0 lg:group-hover:ml-2"></i>
<i class="fas fa-chevron-down text-base-content/60 lg:before:hidden lg:group-hover:before:inline ml-2 lg:ml-0 lg:group-hover:ml-2"
:class="{ 'rotate-180': open }"
style="transition: transform 0.2s ease"></i>
</div>
<div x-show="open" x-collapse>
{{ slot }}
</div>
</li>
<div class="collapse lg:hidden lg:group-hover:block" id="{{ title|slugify }}">
{{ slot }}
</div>

View File

@@ -1,94 +0,0 @@
<div class="card bg-base-100 shadow-xl mb-2 transaction-item">
<div class="card-body p-2 flex items-center gap-3" data-bs-toggle="collapse" data-bs-target="#{{ transaction.id }}" role="button" aria-expanded="false" aria-controls="{{ transaction.id }}">
<!-- Main visible content -->
<div class="flex flex-col lg:flex-row lg:items-center w-full gap-3">
<!-- Type indicator -->
<div class="w-8">
{% if transaction.type == 'IN' %}
<span class="badge badge-success"></span>
{% else %}
<span class="badge badge-error"></span>
{% endif %}
</div>
<!-- Payment status -->
<div class="w-8">
{% if transaction.is_paid %}
<span class="badge badge-success"></span>
{% else %}
<span class="badge badge-warning"></span>
{% endif %}
</div>
<!-- Description -->
<div class="flex-grow">
<span class="font-medium">{{ transaction.description }}</span>
</div>
<!-- Amount -->
<div class="text-right whitespace-nowrap">
<span class="{% if transaction.type == 'IN' %}text-green-400{% else %}text-red-400{% endif %}">
{{ transaction.amount }}
</span>
{% if transaction.exchanged_amount %}
<br>
<small class="text-base-content/60">
{{ transaction.exchanged_amount.prefix }}{{ transaction.exchanged_amount.amount }}{{ transaction.exchanged_amount.suffix }}
</small>
{% endif %}
</div>
</div>
</div>
<!-- Expandable details -->
<div class="collapse" id="{{ transaction.id }}">
<div class="card-body p-3 transaction-details">
<div class="grid grid-cols-1 md:grid-cols-2">
<div>
<dl class="grid grid-cols-3">
<dt class="col-span-1">Date</dt>
<dd class="col-span-2">{{ transaction.date|date:"Y-m-d" }}</dd>
<dt class="col-span-1">Reference Date</dt>
<dd class="col-span-2">{{ transaction.reference_date|date:"Y-m" }}</dd>
<dt class="col-span-1">Account</dt>
<dd class="col-span-2">{{ transaction.account.name }}</dd>
<dt class="col-span-1">Category</dt>
<dd class="col-span-2">{{ transaction.category|default:"-" }}</dd>
</dl>
</div>
<div>
<dl class="grid grid-cols-3">
{% if transaction.tags.exists %}
<dt class="col-span-1">Tags</dt>
<dd class="col-span-2">
{% for tag in transaction.tags.all %}
<span class="badge badge-secondary">{{ tag.name }}</span>
{% endfor %}
</dd>
{% endif %}
{% if transaction.installment_plan %}
<dt class="col-span-1">Installment</dt>
<dd class="col-span-2">
{{ transaction.installment_id }} of {{ transaction.installment_plan.total_installments }}
</dd>
{% endif %}
{% if transaction.recurring_transaction %}
<dt class="col-span-1">Recurring</dt>
<dd class="col-span-2">Yes</dd>
{% endif %}
{% if transaction.notes %}
<dt class="col-span-1">Notes</dt>
<dd class="col-span-2">{{ transaction.notes }}</dd>
{% endif %}
</dl>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,12 +1,11 @@
{% load i18n %}
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar"
_="on change from #transactions-list or htmx:afterSettle from window
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar" _="on change from #transactions-list or htmx:afterSettle from window
if #actions-bar then
if no <input[type='checkbox']:checked/> in #transactions-list
if #actions-bar
add .slide-in-bottom-reverse then settle
add .slide-in-bottom-short-reverse then settle
then add .hidden to #actions-bar
then remove .slide-in-bottom-reverse
then remove .slide-in-bottom-short-reverse
end
else
if #actions-bar
@@ -17,54 +16,51 @@
end
end
end">
<div class="card bg-base-300 shadow slide-in-bottom max-w-[90vw] card-border">
<div class="card bg-base-300 shadow slide-in-bottom-short max-w-[90vw] card-border mt-5">
<div class="card-body flex-row p-2 flex justify-between items-center gap-3 overflow-x-auto">
{% spaceless %}
<div class="font-bold text-md ms-2" id="selected-count">0</div>
<div class="divider divider-horizontal m-0"></div>
<div>
<button role="button" class="btn btn-secondary btn-sm" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-regular fa-square-check fa-fw"></i>
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<ul class="dropdown-menu menu">
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
</a>
</li>
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square text-error me-3"></i>{% translate 'Unselect All' %}
</a>
</li>
</ul>
</div>
<div class="divider divider-horizontal m-0"></div>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_undelete' %}"
hx-include=".transaction"
data-tippy-content="{% translate 'Restore' %}">
<i class="fa-solid fa-trash-arrow-up fa-fw"></i>
<div class="font-bold text-md ms-2" id="selected-count">0</div>
<div class="divider divider-horizontal m-0"></div>
<div>
<button role="button" class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-regular fa-square-check fa-fw"></i>
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction"
hx-trigger="confirmed"
data-tippy-content="{% translate 'Delete' %}"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash text-error"></i>
</button>
<div class="divider divider-horizontal m-0"></div>
<div class="join"
_="on selected_transactions_updated from #actions-bar
<ul class="dropdown-menu menu">
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
</a>
</li>
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square text-error me-3"></i>{% translate 'Unselect All' %}
</a>
</li>
<li>
<a class="cursor-pointer"
_="on click for checkbox in <#transactions-list input[type='checkbox']/> set checkbox.checked to (not checkbox.checked) end then call me.blur() then trigger change">
<i class="fa-solid fa-arrow-right-arrow-left text-info me-3"></i>{% translate 'Invert election' %}
</a>
</li>
</ul>
</div>
<div class="divider divider-horizontal m-0"></div>
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_undelete' %}" hx-include=".transaction"
data-tippy-content="{% translate 'Restore' %}">
<i class="fa-solid fa-trash-arrow-up fa-fw"></i>
</button>
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_delete' %}" hx-include=".transaction"
hx-trigger="confirmed" data-tippy-content="{% translate 'Delete' %}" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}" _="install prompt_swal">
<i class="fa-solid fa-trash text-error"></i>
</button>
<div class="divider divider-horizontal m-0"></div>
<div class="join" _="on selected_transactions_updated from #actions-bar
set realTotal to math.bignumber(0)
set flatTotal to math.bignumber(0)
set transactions to <.transaction:has(input[name='transactions']:checked)/>
@@ -101,145 +97,121 @@
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
end">
<button class="btn btn-secondary btn-sm join-item"
_="on click
<button class="btn btn-secondary btn-sm join-item" _="on click
set original_value to #real-total-front's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #real-total-front's innerText
wait 1s
put original_value into #real-total-front's innerText
end">
<i class="fa-solid fa-plus fa-fw me-md-2"></i>
<span class="hidden md:inline-block" id="real-total-front">0</span>
put '{% translate "copied!" %}' into #real-total-front's innerText wait 1s put original_value
into #real-total-front's innerText end">
<i class="fa-solid fa-plus fa-fw me-md-2"></i>
<span class="hidden md:inline-block" id="real-total-front">0</span>
</button>
<div>
<button class="join-item btn btn-sm btn-secondary" type="button" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false">
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<div>
<button class="join-item btn btn-sm btn-secondary"
type="button"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false">
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end menu">
<li class="cursor-pointer"
_="on click
<ul class="dropdown-menu dropdown-menu-end menu">
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-flat-total's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-flat-total
wait 1s
put original_value into #calc-menu-flat-total
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Flat Total" %}
</div>
<div id="calc-menu-flat-total">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-flat-total wait 1s put original_value into
#calc-menu-flat-total end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Flat Total" %}
</div>
<div id="calc-menu-flat-total">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-real-total's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-real-total
wait 1s
put original_value into #calc-menu-real-total
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Real Total" %}
</div>
<div id="calc-menu-real-total">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-real-total wait 1s put original_value into
#calc-menu-real-total end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Real Total" %}
</div>
<div id="calc-menu-real-total">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-mean's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-mean
wait 1s
put original_value into #calc-menu-mean
end">
<div class="p-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Mean" %}
</div>
<div id="calc-menu-mean">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-mean wait 1s put original_value into
#calc-menu-mean end">
<div class="p-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Mean" %}
</div>
<div id="calc-menu-mean">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-max's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-max
wait 1s
put original_value into #calc-menu-max
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Max" %}
</div>
<div id="calc-menu-max">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-max wait 1s put original_value into
#calc-menu-max end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Max" %}
</div>
<div id="calc-menu-max">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-min's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-min
wait 1s
put original_value into #calc-menu-min
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Min" %}
</div>
<div id="calc-menu-min">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-min wait 1s put original_value into
#calc-menu-min end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Min" %}
</div>
<div id="calc-menu-min">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-count's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-count
wait 1s
put original_value into #calc-menu-count
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Count" %}
</div>
<div id="calc-menu-count">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-count wait 1s put original_value into
#calc-menu-count end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Count" %}
</div>
<div id="calc-menu-count">
0
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
{% endspaceless %}
</div>
</div>
</div>
</div>

View File

@@ -1,12 +1,11 @@
{% load i18n %}
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar"
_="on change from #transactions-list or htmx:afterSettle from window
<div class="sticky bottom-4 left-0 right-0 z-1000 hidden mx-auto w-fit" id="actions-bar" _="on change from #transactions-list or htmx:afterSettle from window
if #actions-bar then
if no <input[type='checkbox']:checked/> in #transactions-list
if #actions-bar
add .slide-in-bottom-reverse then settle
add .slide-in-bottom-short-reverse then settle
then add .hidden to #actions-bar
then remove .slide-in-bottom-reverse
then remove .slide-in-bottom-short-reverse
end
else
if #actions-bar
@@ -17,86 +16,76 @@
end
end
end">
<div class="card bg-base-300 shadow slide-in-bottom max-w-[90vw] card-border">
<div class="card bg-base-300 shadow slide-in-bottom-short max-w-[90vw] card-border mt-5">
<div class="card-body flex-row p-2 flex justify-between items-center gap-3 overflow-x-auto">
{% spaceless %}
<div class="font-bold text-md ms-2" id="selected-count">0</div>
<div class="divider divider-horizontal m-0"></div>
<div class="font-bold text-md ms-2" id="selected-count">0</div>
<div class="divider divider-horizontal m-0"></div>
<div>
<button role="button" class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-regular fa-square-check fa-fw"></i>
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<ul class="dropdown-menu menu">
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
</a>
</li>
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square text-error me-3"></i>{% translate 'Unselect All' %}
</a>
</li>
<li>
<a class="cursor-pointer"
_="on click for checkbox in <#transactions-list input[type='checkbox']/> set checkbox.checked to (not checkbox.checked) end then call me.blur() then trigger change">
<i class="fa-solid fa-arrow-right-arrow-left text-info me-3"></i>{% translate 'Invert selection' %}
</a>
</li>
</ul>
</div>
<div class="divider divider-horizontal m-0"></div>
<div class="join">
<button class="btn btn-secondary join-item btn-sm" hx-get="{% url 'transactions_bulk_edit' %}"
hx-target="#generic-offcanvas" hx-include=".transaction" data-tippy-content="{% translate 'Edit' %}">
<i class="fa-solid fa-pencil"></i>
</button>
<div>
<button role="button" class="btn btn-secondary btn-sm" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-regular fa-square-check fa-fw"></i>
<button type="button" role="button" class="join-item btn btn-sm btn-secondary" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<ul class="dropdown-menu menu">
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
<i class="fa-regular fa-square-check text-success me-3"></i>{% translate 'Select All' %}
<a class="cursor-pointer" hx-get="{% url 'transactions_bulk_unpay' %}" hx-include=".transaction">
<i class="fa-regular fa-circle text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
</a>
</li>
<li>
<a class="cursor-pointer"
_="on click set <#transactions-list input[type='checkbox']/>'s checked to false then call me.blur() then trigger change">
<i class="fa-regular fa-square text-error me-3"></i>{% translate 'Unselect All' %}
<a class="cursor-pointer" hx-get="{% url 'transactions_bulk_pay' %}" hx-include=".transaction">
<i class="fa-regular fa-circle-check text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
</a>
</li>
</ul>
</div>
<div class="divider divider-horizontal m-0"></div>
<div class="join">
<button class="btn btn-secondary join-item btn-sm"
hx-get="{% url 'transactions_bulk_edit' %}"
hx-target="#generic-offcanvas"
hx-include=".transaction"
data-tippy-content="{% translate 'Edit' %}">
<i class="fa-solid fa-pencil"></i>
</button>
<div>
<button type="button" role="button" class="join-item btn btn-sm btn-secondary"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<ul class="dropdown-menu menu">
<li>
<a class="cursor-pointer"
hx-get="{% url 'transactions_bulk_unpay' %}"
hx-include=".transaction">
<i class="fa-regular fa-circle text-red-400 fa-fw me-3"></i>{% translate 'Mark as unpaid' %}
</a>
</li>
<li>
<a class="cursor-pointer"
hx-get="{% url 'transactions_bulk_pay' %}"
hx-include=".transaction">
<i class="fa-regular fa-circle-check text-green-400 fa-fw me-3"></i>{% translate 'Mark as paid' %}
</a>
</li>
</ul>
</div>
</div>
<button class="btn btn-secondary btn-sm"
hx-get="{% url 'transactions_bulk_clone' %}"
hx-include=".transaction"
data-tippy-content="{% translate 'Duplicate' %}">
<i class="fa-solid fa-clone fa-fw"></i>
</button>
<button class="btn btn-error btn-sm"
hx-get="{% url 'transactions_bulk_delete' %}"
hx-include=".transaction"
hx-trigger="confirmed"
data-tippy-content="{% translate 'Delete' %}"
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash"></i>
</button>
<div class="divider divider-horizontal m-0"></div>
<div class="join"
_="on selected_transactions_updated from #actions-bar
</div>
<button class="btn btn-secondary btn-sm" hx-get="{% url 'transactions_bulk_clone' %}" hx-include=".transaction"
data-tippy-content="{% translate 'Duplicate' %}">
<i class="fa-solid fa-clone fa-fw"></i>
</button>
<button class="btn btn-error btn-sm" hx-get="{% url 'transactions_bulk_delete' %}" hx-include=".transaction"
hx-trigger="confirmed" data-tippy-content="{% translate 'Delete' %}" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete them!" %}" _="install prompt_swal">
<i class="fa-solid fa-trash"></i>
</button>
<div class="divider divider-horizontal m-0"></div>
<div class="join" _="on selected_transactions_updated from #actions-bar
set realTotal to math.bignumber(0)
set flatTotal to math.bignumber(0)
set transactions to <.transaction:has(input[name='transactions']:checked)/>
@@ -133,145 +122,121 @@
put mean.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-mean's innerText
put flatAmountValues.length.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 40}) into #calc-menu-count's innerText
end">
<button class="btn btn-secondary btn-sm join-item"
_="on click
<button class="btn btn-secondary btn-sm join-item" _="on click
set original_value to #real-total-front's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #real-total-front's innerText
wait 1s
put original_value into #real-total-front's innerText
end">
<i class="fa-solid fa-plus fa-fw me-md-2"></i>
<span class="hidden md:inline-block" id="real-total-front">0</span>
put '{% translate "copied!" %}' into #real-total-front's innerText wait 1s put original_value
into #real-total-front's innerText end">
<i class="fa-solid fa-plus fa-fw me-md-2"></i>
<span class="hidden md:inline-block" id="real-total-front">0</span>
</button>
<div>
<button class="join-item btn btn-sm btn-secondary" type="button" data-bs-toggle="dropdown"
data-bs-auto-close="outside" aria-expanded="false">
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<div>
<button class="join-item btn btn-sm btn-secondary"
type="button"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false">
<i class="fa-solid fa-chevron-down fa-xs"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end menu">
<li class="cursor-pointer"
_="on click
<ul class="dropdown-menu dropdown-menu-end menu">
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-flat-total's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-flat-total
wait 1s
put original_value into #calc-menu-flat-total
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Flat Total" %}
</div>
<div id="calc-menu-flat-total">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-flat-total wait 1s put original_value into
#calc-menu-flat-total end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Flat Total" %}
</div>
<div id="calc-menu-flat-total">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-real-total's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-real-total
wait 1s
put original_value into #calc-menu-real-total
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Real Total" %}
</div>
<div id="calc-menu-real-total">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-real-total wait 1s put original_value into
#calc-menu-real-total end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Real Total" %}
</div>
<div id="calc-menu-real-total">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-mean's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-mean
wait 1s
put original_value into #calc-menu-mean
end">
<div class="p-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Mean" %}
</div>
<div id="calc-menu-mean">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-mean wait 1s put original_value into
#calc-menu-mean end">
<div class="p-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Mean" %}
</div>
<div id="calc-menu-mean">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-max's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-max
wait 1s
put original_value into #calc-menu-max
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Max" %}
</div>
<div id="calc-menu-max">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-max wait 1s put original_value into
#calc-menu-max end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Max" %}
</div>
<div id="calc-menu-max">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-min's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-min
wait 1s
put original_value into #calc-menu-min
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Min" %}
</div>
<div id="calc-menu-min">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-min wait 1s put original_value into
#calc-menu-min end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Min" %}
</div>
<div id="calc-menu-min">
0
</div>
</div>
</li>
<li class="cursor-pointer"
_="on click
</div>
</li>
<li class="cursor-pointer" _="on click
set original_value to #calc-menu-count's innerText
writeText(original_value) on navigator.clipboard
put '{% translate "copied!" %}' into #calc-menu-count
wait 1s
put original_value into #calc-menu-count
end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Count" %}
</div>
<div id="calc-menu-count">
0
</div>
put '{% translate "copied!" %}' into #calc-menu-count wait 1s put original_value into
#calc-menu-count end">
<div class="py-1 px-3">
<div>
<div class="text-base-content/60 text-xs font-medium">
{% trans "Count" %}
</div>
<div id="calc-menu-count">
0
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
{% endspaceless %}
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<div class="alert {{ alert.css_class }}" role="alert"{% if alert.css_id %} id="{{ alert.css_id }}"{% endif %}>
{{ content|safe }}
{% if dismiss %}<button type="button" class="btn btn-sm btn-circle btn-ghost" data-bs-dismiss="alert" aria-label="Close"></button>{% endif %}
<span>{{ content|safe }}</span>
{% if dismiss %}<button type="button" class="btn btn-sm btn-circle btn-ghost ml-auto" aria-label="Close" _="on click remove closest .alert"></button>{% endif %}
</div>

View File

@@ -1,7 +1,7 @@
{% if field.help_text %}
{% if help_text_inline %}
<span id="{{ field.auto_id }}_helptext" class="label text-wrap">{{ field.help_text|safe}}</span>
<span id="{{ field.auto_id }}_helptext" class="label text-wrap block">{{ field.help_text|safe}}</span>
{% else %}
<p {% if field.auto_id %}id="{{ field.auto_id }}_helptext" {% endif %}class="label text-wrap">{{ field.help_text|safe }}</p>
<p {% if field.auto_id %}id="{{ field.auto_id }}_helptext" {% endif %}class="label text-wrap block">{{ field.help_text|safe }}</p>
{% endif %}
{% endif %}

View File

@@ -1,4 +1,5 @@
{% load crispy_forms_field %}
{% load crispy_extra %}
{% if field.is_hidden %}
{{ field }}
@@ -7,33 +8,43 @@
<fieldset class="fieldset{% if field_class %} {{ field_class }}{% endif %}">
{% if field.label and form_show_labels %}
<legend class="fieldset-legend{{ label_class }}{% if field.field.required %} requiredField{% endif %}">
<label for="{{ field.id_for_label }}" class="fieldset-legend{% if label_class %} {{ label_class }}{% endif %}{% if field.field.required %} requiredField{% endif %}">
{{ field.label }}{% if field.field.required %}<span class="asteriskField">*</span>{% endif %}
</legend>
</label>
{% endif %}
<label class="{% if input_size %} {{ input_size }}{% endif %}{% if field.errors %} input-error{% endif %}">
<div class="join w-full{% if input_size %} {{ input_size }}{% endif %}">
{# prepend #}
{% if crispy_prepended_text %}
{{ crispy_prepended_text }}
<span class="join-item flex items-center px-3 bg-base-200 border border-base-300">{{ crispy_prepended_text }}</span>
{% endif %}
{# input #}
{% if field|is_select %}
{% if field.errors %}
{% crispy_field field 'class' 'select-error grow' %}
{% crispy_field field 'class' 'select select-error join-item grow' %}
{% else %}
{% crispy_field field 'class' 'grow' %}
{% crispy_field field 'class' 'select join-item grow' %}
{% endif %}
{% elif field|is_input %}
{% if field.errors %}
{% crispy_field field 'class' 'input input-error join-item grow' %}
{% else %}
{% crispy_field field 'class' 'input join-item grow' %}
{% endif %}
{% else %}
{% crispy_field field 'class' 'grow' %}
{% if field.errors %}
{% crispy_field field 'class' 'input input-error join-item grow' %}
{% else %}
{% crispy_field field 'class' 'input join-item grow' %}
{% endif %}
{% endif %}
{# append #}
{% if crispy_appended_text %}
{{ crispy_appended_text }}
<span class="join-item flex items-center px-3 bg-base-200 border border-base-300">{{ crispy_appended_text }}</span>
{% endif %}
</label>
</div>
{# help text as label paragraph #}
{% if not help_text_inline %}

View File

@@ -56,7 +56,15 @@
</td>
<td class="table-col-auto">{% if service.is_active %}<i class="fa-solid fa-circle text-success"></i>{% else %}
<i class="fa-solid fa-circle text-error"></i>{% endif %}</td>
<td class="table-col-auto">{{ service.name }}</td>
<td>
{{ service.name }}
{% if service.failure_count > 0 %}
<span class="badge badge-error gap-1" data-tippy-content="{% blocktrans count counter=service.failure_count %}{{ counter }} consecutive failure{% plural %}{{ counter }} consecutive failures{% endblocktrans %}">
<i class="fa-solid fa-triangle-exclamation fa-fw"></i>
{{ service.failure_count }}
</span>
{% endif %}
</td>
<td>{{ service.get_service_type_display }}</td>
<td>{{ service.target_currencies.count }} {% trans 'currencies' %}, {{ service.target_accounts.count }} {% trans 'accounts' %}</td>
<td>{{ service.last_fetch|date:"SHORT_DATETIME_FORMAT" }}</td>

View File

@@ -1,183 +0,0 @@
{% load cache_access %}
{% load settings %}
{% load static %}
{% load i18n %}
{% load active_link %}
<nav class="navbar navbar-expand-lg border-bottom bg-body-tertiary" hx-boost="true">
<div class="container-fluid">
<a class="navbar-brand fw-bold text-primary font-base" href="{% url 'index' %}">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="40" width="40" title="WYGIWYH"/>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent"
aria-controls="navbarContent" aria-expanded="false" aria-label={% translate "Toggle navigation" %}>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto mb-3 mb-lg-0 nav-underline" hx-push-url="true">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='monthly_overview||yearly_overview_currency||yearly_overview_account||calendar' %}"
href="#"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{% translate 'Overview' %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item {% active_link views='monthly_overview' %}"
href="{% url 'monthly_index' %}">{% translate 'Monthly' %}</a></li>
<li><a class="dropdown-item {% active_link views='yearly_overview_currency' %}"
href="{% url 'yearly_index_currency' %}">{% translate 'Yearly by currency' %}</a></li>
<li><a class="dropdown-item {% active_link views='yearly_overview_account' %}"
href="{% url 'yearly_index_account' %}">{% translate 'Yearly by account' %}</a></li>
<li><a class="dropdown-item {% active_link views='calendar' %}"
href="{% url 'calendar_index' %}">{% translate 'Calendar' %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='net_worth_current||net_worth_projected' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{% translate 'Net Worth' %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item {% active_link views='net_worth_current' %}"
href="{% url 'net_worth_current' %}">{% translate 'Current' %}</a></li>
<li><a class="dropdown-item {% active_link views='net_worth_projected' %}"
href="{% url 'net_worth_projected' %}">{% translate 'Projected' %}</a></li>
</ul>
</li>
<li class="nav-item">
<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||quick_transactions_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{% translate 'Transactions' %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item {% active_link views='transactions_all_index' %}"
href="{% url 'transactions_all_index' %}">{% translate 'All' %}</a></li>
<li>
{% settings "ENABLE_SOFT_DELETE" as enable_soft_delete %}
{% if enable_soft_delete %}
<li><a class="dropdown-item {% active_link views='transactions_trash_index' %}"
href="{% url 'transactions_trash_index' %}">{% translate 'Trash Can' %}</a></li>
<li>
{% 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' %}"
href="{% url 'recurring_trasanctions_index' %}">{% translate 'Recurring Transactions' %}</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='dca_strategy_index||dca_strategy_detail_index||unit_price_calculator||currency_converter' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{% translate 'Tools' %}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item {% active_link views='dca_strategy_index||dca_strategy_detail_index' %}"
href="{% url 'dca_strategy_index' %}">{% translate 'Dollar Cost Average Tracker' %}</a></li>
<li>
<li><a class="dropdown-item {% active_link views='unit_price_calculator' %}"
href="{% url 'unit_price_calculator' %}">{% translate 'Unit Price Calculator' %}</a></li>
<li>
<li><a class="dropdown-item {% active_link views='currency_converter' %}"
href="{% url 'currency_converter' %}">{% translate 'Currency Converter' %}</a></li>
<li>
</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||users_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{% translate 'Management' %}
</a>
<ul class="dropdown-menu">
<li><h6 class="dropdown-header">{% trans 'Transactions' %}</h6></li>
<li><a class="dropdown-item {% active_link views='categories_index' %}"
href="{% url 'categories_index' %}">{% translate 'Categories' %}</a></li>
<li><a class="dropdown-item {% active_link views='tags_index' %}"
href="{% url 'tags_index' %}">{% translate 'Tags' %}</a></li>
<li><a class="dropdown-item {% active_link views='entities_index' %}"
href="{% url 'entities_index' %}">{% translate 'Entities' %}</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Accounts' %}</h6></li>
<li><a class="dropdown-item {% active_link views='accounts_index' %}"
href="{% url 'accounts_index' %}">{% translate 'Accounts' %}</a></li>
<li><a class="dropdown-item {% active_link views='account_groups_index' %}"
href="{% url 'account_groups_index' %}">{% translate 'Account Groups' %}</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Currencies' %}</h6></li>
<li><a class="dropdown-item {% active_link views='currencies_index' %}"
href="{% url 'currencies_index' %}">{% translate 'Currencies' %}</a></li>
<li><a class="dropdown-item {% active_link views='exchange_rates_index' %}"
href="{% url 'exchange_rates_index' %}">{% translate 'Exchange Rates' %}</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Automation' %}</h6></li>
<li><a class="dropdown-item {% active_link views='rules_index' %}"
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
{% if user.is_superuser %}
<li><a class="dropdown-item {% active_link views='export_index' %}"
href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li>
{% endif %}
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
{% if user.is_superuser %}
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Admin' %}</h6></li>
<li><a class="dropdown-item {% active_link views='users_index' %}"
href="{% url 'users_index' %}">{% translate 'Users' %}</a></li>
<li>
<a class="dropdown-item"
href="{% url 'admin:index' %}"
hx-boost="false"
data-tippy-placement="right"
data-tippy-content="{% translate "Only use this if you know what you're doing" %}">
{% translate 'Django Admin' %}
</a>
</li>
{% endif %}
</ul>
</li>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0 gap-3">
{% get_update_check as update_check %}
{% if update_check.update_available %}
<li class="nav-item my-auto">
<a class="badge text-bg-secondary text-decoration-none cursor-pointer" href="https://github.com/eitchtee/WYGIWYH/releases/latest" target="_blank"><i class="fa-solid fa-circle-info fa-fw me-2"></i>v.{{ update_check.latest_version }} {% translate 'is available' %}!</a>
</li>
{% endif %}
<li class="nav-item">
<div class="nav-link lg:text-2xl! cursor-pointer"
data-tippy-placement="left" data-tippy-content="{% trans "Calculator" %}"
_="on click trigger show on #calculator">
<i class="fa-solid fa-calculator"></i>
<span class="d-lg-none d-inline">{% trans "Calculator" %}</span>
</div>
</li>
<li class="w-100">{% include 'includes/navbar/user_menu.html' %}</li>
</ul>
</div>
</div>
</nav>

View File

@@ -10,9 +10,9 @@ behavior htmx_error_handler
icon: 'warning',
timer: 60000,
customClass: {
confirmButton: 'btn btn-warning' -- Optional: different button style
confirmButton: 'btn btn-warning'
},
buttonsStyling: true
buttonsStyling: false
})
else
call Swal.fire({
@@ -23,7 +23,7 @@ behavior htmx_error_handler
customClass: {
confirmButton: 'btn btn-primary'
},
buttonsStyling: true
buttonsStyling: false
})
end
then log event

View File

@@ -135,138 +135,105 @@
<c-components.sidebar-menu-header title=""></c-components.sidebar-menu-header>
<div role="button"
data-bs-toggle="collapse"
data-bs-target="#collapsible-panel"
aria-expanded="false"
aria-controls="collapsible-panel"
class="text-xs flex items-center no-underline ps-3 p-2 rounded-box sidebar-item cursor-pointer {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="sidebar-active" %}">
<i class="fa-solid fa-toolbox fa-fw"></i>
<span class="ml-3 font-medium lg:group-hover:truncate lg:group-focus:truncate lg:group-hover:text-ellipsis lg:group-focus:text-ellipsis">
{% translate 'Management' %}
</span>
<i class="fa-solid fa-chevron-right fa-fw ml-auto pe-2"></i>
</div>
<c-components.sidebar-collapsible-panel
title="{% translate 'Management' %}"
icon="fa-solid fa-toolbox"
active="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">
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Categories' %}"
url='categories_index'
active="categories_index"
icon="fa-solid fa-icons">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Tags' %}"
url='tags_index'
active="tags_index"
icon="fa-solid fa-hashtag">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Entities' %}"
url='entities_index'
active="entities_index"
icon="fa-solid fa-user-group">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Accounts' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Accounts' %}"
url='accounts_index'
active="accounts_index"
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Account Groups' %}"
url='account_groups_index'
active="account_groups_index"
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Currencies' %}"
url='currencies_index'
active="currencies_index"
icon="fa-solid fa-coins">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Exchange Rates' %}"
url='exchange_rates_index'
active="exchange_rates_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Automation' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Rules' %}"
url='rules_index'
active="rules_index"
icon="fa-solid fa-pen-ruler">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Import' %}"
url='import_profiles_index'
active="import_profiles_index"
icon="fa-solid fa-file-import">
</c-components.sidebar-menu-item>
{% if user.is_superuser %}
<c-components.sidebar-menu-item
title="{% translate 'Export and Restore' %}"
url='export_index'
active="export_index"
icon="fa-solid fa-file-export">
</c-components.sidebar-menu-item>
{% endif %}
<c-components.sidebar-menu-item
title="{% translate 'Automatic Exchange Rates' %}"
url='automatic_exchange_rates_index'
active="automatic_exchange_rates_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
{% if user.is_superuser %}
<c-components.sidebar-menu-header title="{% translate 'Admin' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Users' %}"
url='users_index'
active="users_index"
icon="fa-solid fa-users">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-url-item
title="{% translate 'Django Admin' %}"
tooltip="{% translate "Only use this if you know what you're doing" %}"
url='/admin/'
icon="fa-solid fa-screwdriver-wrench">
</c-components.sidebar-menu-url-item>
{% endif %}
</c-components.sidebar-collapsible-panel>
</ul>
<div class="mt-auto p-2 w-full">
<div id="collapsible-panel"
class="bs collapse p-0 absolute bottom-0 left-0 w-full z-30 max-h-dvh {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index||users_index' css_class="show" %}">
<div class="h-dvh bg-base-300 flex flex-col">
<div
class="items-center p-4 border-b border-base-content/10 sidebar-submenu-header text-base-content">
<div class="flex items-center sidebar-submenu-title">
<i class="fa-solid fa-toolbox fa-fw lg:group-hover:me-2 me-2 lg:me-0"></i>
<h5 class="text-lg font-semibold text-base-content m-0">
{% trans 'Management' %}
</h5>
</div>
<button type="button" class="btn btn-ghost btn-sm btn-circle" aria-label="{% trans 'Close' %}"
data-bs-toggle="collapse"
data-bs-target="#collapsible-panel"
aria-expanded="true"
aria-controls="collapsible-panel">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<ul class="sidebar-item-list list-none p-3 flex flex-col gap-1 whitespace-nowrap lg:group-hover:animate-[disable-pointer-events] overflow-y-auto lg:overflow-y-hidden lg:hover:overflow-y-auto overflow-x-hidden"
style="animation-duration: 100ms">
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Categories' %}"
url='categories_index'
active="categories_index"
icon="fa-solid fa-icons">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Tags' %}"
url='tags_index'
active="tags_index"
icon="fa-solid fa-hashtag">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Entities' %}"
url='entities_index'
active="entities_index"
icon="fa-solid fa-user-group">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Accounts' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Accounts' %}"
url='accounts_index'
active="accounts_index"
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Account Groups' %}"
url='account_groups_index'
active="account_groups_index"
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Currencies' %}"
url='currencies_index'
active="currencies_index"
icon="fa-solid fa-coins">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Exchange Rates' %}"
url='exchange_rates_index'
active="exchange_rates_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Automation' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Rules' %}"
url='rules_index'
active="rules_index"
icon="fa-solid fa-pen-ruler">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Import' %}"
url='import_profiles_index'
active="import_profiles_index"
icon="fa-solid fa-file-import">
</c-components.sidebar-menu-item>
{% if user.is_superuser %}
<c-components.sidebar-menu-item
title="{% translate 'Export and Restore' %}"
url='export_index'
active="export_index"
icon="fa-solid fa-file-export">
</c-components.sidebar-menu-item>
{% endif %}
<c-components.sidebar-menu-item
title="{% translate 'Automatic Exchange Rates' %}"
url='automatic_exchange_rates_index'
active="automatic_exchange_rates_index"
icon="fa-solid fa-right-left">
</c-components.sidebar-menu-item>
{% if user.is_superuser %}
<c-components.sidebar-menu-header title="{% translate 'Admin' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Users' %}"
url='users_index'
active="users_index"
icon="fa-solid fa-users">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-url-item
title="{% translate 'Django Admin' %}"
tooltip="{% translate "Only use this if you know what you're doing" %}"
url='/admin/'
icon="fa-solid fa-screwdriver-wrench">
</c-components.sidebar-menu-url-item>
{% endif %}
</ul>
</div>
</div>
{% get_update_check as update_check %}
{% if update_check.update_available %}
<div class="my-3 sidebar-item">

View File

@@ -0,0 +1,250 @@
{% load i18n %}
<div hx-get="{% url 'insights_month_by_month' %}" hx-trigger="updated from:window" class="show-loading"
hx-swap="outerHTML" hx-include="#year-selector, #group-by-selector-month">
{# Hidden input to hold the year value #}
<input type="hidden" name="year" id="year-selector" value="{{ selected_year }}" _="on change trigger updated">
{# Tabs for Categories/Tags/Entities #}
<div class="h-full text-center mb-4">
<div class="tabs tabs-box mx-auto w-fit" role="group" id="group-by-selector-month" _="on change trigger updated">
<label class="tab">
<input type="radio"
name="group_by"
id="categories-view-month"
autocomplete="off"
value="categories"
aria-label="{% trans 'Categories' %}"
{% if group_by == "categories" %}checked{% endif %}>
<i class="fa-solid fa-icons fa-fw me-2"></i>
{% trans 'Categories' %}
</label>
<label class="tab">
<input type="radio"
name="group_by"
id="tags-view-month"
autocomplete="off"
value="tags"
aria-label="{% trans 'Tags' %}"
{% if group_by == "tags" %}checked{% endif %}>
<i class="fa-solid fa-hashtag fa-fw me-2"></i>
{% trans 'Tags' %}
</label>
<label class="tab">
<input type="radio"
name="group_by"
id="entities-view-month"
autocomplete="off"
value="entities"
aria-label="{% trans 'Entities' %}"
{% if group_by == "entities" %}checked{% endif %}>
<i class="fa-solid fa-user-group fa-fw me-2"></i>
{% trans 'Entities' %}
</label>
</div>
</div>
{% if data.items %}
<div class="card bg-base-100 card-border">
<div class="card-body">
{# Year dropdown - left aligned #}
{% if data.available_years %}
<div class="mb-4">
<div>
<button class="btn btn-ghost" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-calendar fa-fw me-1"></i>
{{ selected_year }}
<i class="fa-solid fa-chevron-down fa-fw ms-1"></i>
</button>
<ul class="dropdown-menu menu">
{% for year in data.available_years %}
<li>
<button class="{% if year == selected_year %}menu-active{% endif %}" type="button"
_="on click remove .menu-active from <li > button/> in the closest <ul/>
then add .menu-active to me
then set the value of #year-selector to '{{ year }}'
then trigger change on #year-selector">
{{ year }}
</button>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th scope="col" class="sticky left-0 bg-base-100 z-10">
{% if group_by == "categories" %}
{% trans 'Category' %}
{% elif group_by == "tags" %}
{% trans 'Tag' %}
{% else %}
{% trans 'Entity' %}
{% endif %}
</th>
<th scope="col" class="font-bold">{% trans 'Total' %}</th>
{% for month in data.months %}
<th scope="col">
{% if month == 1 %}{% trans 'Jan' %}
{% elif month == 2 %}{% trans 'Feb' %}
{% elif month == 3 %}{% trans 'Mar' %}
{% elif month == 4 %}{% trans 'Apr' %}
{% elif month == 5 %}{% trans 'May' %}
{% elif month == 6 %}{% trans 'Jun' %}
{% elif month == 7 %}{% trans 'Jul' %}
{% elif month == 8 %}{% trans 'Aug' %}
{% elif month == 9 %}{% trans 'Sep' %}
{% elif month == 10 %}{% trans 'Oct' %}
{% elif month == 11 %}{% trans 'Nov' %}
{% elif month == 12 %}{% trans 'Dec' %}
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for item_id, item in data.items.items %}
<tr>
<th class="text-nowrap sticky left-0 bg-base-100 z-10">
{% if item.name %}
{{ item.name }}
{% else %}
{% if group_by == "categories" %}
{% trans 'Uncategorized' %}
{% elif group_by == "tags" %}
{% trans 'Untagged' %}
{% else %}
{% trans 'No entity' %}
{% endif %}
{% endif %}
</th>
{# Total column for this item #}
<td class="text-nowrap font-semibold bg-base-200">
{% for currency_id, currency_data in item.total.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
</td>
{# Month columns #}
{% for month in data.months %}
<td class="text-nowrap">
{% with month_data=item.month_totals %}
{% for m, m_data in month_data.items %}
{% if m == month %}
{% for currency_id, currency_data in m_data.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
{% endif %}
{% empty %}
-
{% endfor %}
{% endwith %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="font-bold bg-base-200">
<th class="sticky left-0 bg-base-200 z-10">{% trans 'Total' %}</th>
{# Grand total #}
<td class="text-nowrap bg-base-300">
{% for currency_id, currency_data in data.grand_total.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
</td>
{# Month totals #}
{% for month in data.months %}
<td class="text-nowrap">
{% with month_total=data.month_totals %}
{% for m, m_data in month_total.items %}
{% if m == month %}
{% for currency_id, currency_data in m_data.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
{% endif %}
{% empty %}
-
{% endfor %}
{% endwith %}
</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{% else %}
<c-msg.empty title="{% translate 'No transactions for this year' %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -0,0 +1,204 @@
{% load i18n %}
<div hx-get="{% url 'insights_year_by_year' %}" hx-trigger="updated from:window" class="show-loading"
hx-swap="outerHTML" hx-include="#group-by-selector">
<div class="h-full text-center mb-4">
<div class="tabs tabs-box mx-auto w-fit" role="group" id="group-by-selector" _="on change trigger updated">
<label class="tab">
<input type="radio"
name="group_by"
id="categories-view"
autocomplete="off"
value="categories"
aria-label="{% trans 'Categories' %}"
{% if group_by == "categories" %}checked{% endif %}>
<i class="fa-solid fa-icons fa-fw me-2"></i>
{% trans 'Categories' %}
</label>
<label class="tab">
<input type="radio"
name="group_by"
id="tags-view"
autocomplete="off"
value="tags"
aria-label="{% trans 'Tags' %}"
{% if group_by == "tags" %}checked{% endif %}>
<i class="fa-solid fa-hashtag fa-fw me-2"></i>
{% trans 'Tags' %}
</label>
<label class="tab">
<input type="radio"
name="group_by"
id="entities-view"
autocomplete="off"
value="entities"
aria-label="{% trans 'Entities' %}"
{% if group_by == "entities" %}checked{% endif %}>
<i class="fa-solid fa-user-group fa-fw me-2"></i>
{% trans 'Entities' %}
</label>
</div>
</div>
{% if data.years %}
<div class="card bg-base-100 card-border">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th scope="col" class="sticky left-0 bg-base-100 z-10">
{% if group_by == "categories" %}
{% trans 'Category' %}
{% elif group_by == "tags" %}
{% trans 'Tag' %}
{% else %}
{% trans 'Entity' %}
{% endif %}
</th>
<th scope="col" class="font-bold">{% trans 'Total' %}</th>
{% for year in data.years %}
<th scope="col">{{ year }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for item_id, item in data.items.items %}
<tr>
<th class="text-nowrap sticky left-0 bg-base-100 z-10">
{% if item.name %}
{{ item.name }}
{% else %}
{% if group_by == "categories" %}
{% trans 'Uncategorized' %}
{% elif group_by == "tags" %}
{% trans 'Untagged' %}
{% else %}
{% trans 'No entity' %}
{% endif %}
{% endif %}
</th>
{# Total column for this item #}
<td class="text-nowrap font-semibold bg-base-200">
{% for currency_id, currency_data in item.total.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
</td>
{# Year columns #}
{% for year in data.years %}
<td class="text-nowrap">
{% with year_data=item.year_totals %}
{% for y, y_data in year_data.items %}
{% if y == year %}
{% for currency_id, currency_data in y_data.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
{% endif %}
{% empty %}
-
{% endfor %}
{% endwith %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="font-bold bg-base-200">
<th class="sticky left-0 bg-base-200 z-10">{% trans 'Total' %}</th>
{# Grand total #}
<td class="text-nowrap bg-base-300">
{% for currency_id, currency_data in data.grand_total.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
</td>
{# Year totals #}
{% for year in data.years %}
<td class="text-nowrap">
{% with year_total=data.year_totals %}
{% for y, y_data in year_total.items %}
{% if y == year %}
{% for currency_id, currency_data in y_data.currencies.items %}
<c-amount.display
:amount="currency_data.final_total"
:prefix="currency_data.currency.prefix"
:suffix="currency_data.currency.suffix"
:decimal_places="currency_data.currency.decimal_places"
color="{% if currency_data.final_total < 0 %}red{% elif currency_data.final_total > 0 %}green{% endif %}"></c-amount.display>
{% if currency_data.exchanged %}
<div class="text-xs text-base-content/60">
<c-amount.display
:amount="currency_data.exchanged.final_total"
:prefix="currency_data.exchanged.currency.prefix"
:suffix="currency_data.exchanged.currency.suffix"
:decimal_places="currency_data.exchanged.currency.decimal_places"></c-amount.display>
</div>
{% endif %}
{% empty %}
-
{% endfor %}
{% endif %}
{% empty %}
-
{% endfor %}
{% endwith %}
</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{% else %}
<c-msg.empty title="{% translate 'No transactions' %}"></c-msg.empty>
{% endif %}
</div>

View File

@@ -121,6 +121,16 @@
hx-get="{% url 'insights_emergency_fund' %}">
{% trans 'Emergency Fund' %}
</button>
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_year_by_year' %}">
{% trans 'Year by Year' %}
</button>
<button class="btn btn-ghost btn-free justify-start text-start" data-bs-target="#v-pills-content"
type="button" role="tab" aria-controls="v-pills-content" aria-selected="false"
hx-get="{% url 'insights_month_by_month' %}">
{% trans 'Month by Month' %}
</button>
</div>
</div>
</div>

View File

@@ -5,52 +5,53 @@
{% load title %}
<!DOCTYPE html>
<html lang="en"
data-theme="{% if request.session.theme == 'wygiwyh_light' %}wygiwyh_light{% else %}wygiwyh_dark{% endif %}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
{% filter site_title %}
{% block title %}
{% endblock title %}
{% endfilter %}
</title>
{% include 'includes/head/favicons.html' %}
{% progressive_web_app_meta %}
{# {% include 'includes/styles.html' %}#}
{% block extra_styles %}{% endblock %}
{% include 'includes/scripts.html' %}
{% block extra_js_head %}{% endblock %}
</head>
<body class="font-mono">
<div _="install htmx_error_handler
{% block body_hyperscript %}{% endblock %}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/mobile_navbar.html' %}
{% include 'includes/sidebar.html' %}
<main class="my-8 px-3">
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
<div class="alert alert-warning my-3" role="alert">
<strong>{% trans "This is a demo!" %}</strong> {% trans "Any data you add here will be wiped in 24hrs or less" %}
<button type="button"
class="btn btn-sm btn-ghost absolute right-2 top-2"
onclick="this.parentElement.style.display='none'"
aria-label="Close"></button>
</div>
</div>
{% endif %}
<div id="content">
{% block content %}
{% endblock content %}
data-theme="{% if request.session.theme == 'wygiwyh_light' %}wygiwyh_light{% else %}wygiwyh_dark{% endif %}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>
{% filter site_title %}
{% block title %}
{% endblock title %}
{% endfilter %}
</title>
{% include 'includes/head/favicons.html' %}
{% progressive_web_app_meta %}
{# {% include 'includes/styles.html' %}#}
{% block extra_styles %}{% endblock %}
{% include 'includes/scripts.html' %}
{% block extra_js_head %}{% endblock %}
</head>
<body class="font-mono">
<div _="install htmx_error_handler
{% block body_hyperscript %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
{% include 'includes/mobile_navbar.html' %}
{% include 'includes/sidebar.html' %}
<main class="my-8 px-3">
{% settings "DEMO" as demo_mode %}
{% if demo_mode %}
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
<div class="alert alert-warning my-3 relative" role="alert">
<strong>{% trans "This is a demo!" %}</strong> {% trans "Any data you add here will be wiped in 24hrs or less"
%}
<button type="button" class="btn btn-sm btn-ghost absolute right-2 top-1/2 -translate-y-1/2"
onclick="this.parentElement.style.display='none'" aria-label="Close"></button>
</div>
{% include "includes/offcanvas.html" %}
{% include "includes/toasts.html" %}
</main>
</div>
{% include "includes/tools/calculator.html" %}
{% block extra_js_body %}
{% endblock extra_js_body %}
</body>
</html>
</div>
{% endif %}
<div id="content">
{% block content %}
{% endblock content %}
</div>
{% include "includes/offcanvas.html" %}
{% include "includes/toasts.html" %}
</main>
</div>
{% include "includes/tools/calculator.html" %}
{% block extra_js_body %}
{% endblock extra_js_body %}
</body>
</html>

View File

@@ -3,35 +3,46 @@
{% regroup transactions by date|customnaturaldate as transactions_by_date %}
<div id="transactions-list">
{% if late_transactions %}
<div id="late-transactions" class="transactions-divider"
x-data="{ open: sessionStorage.getItem('late-transactions') !== 'false' }"
x-init="if (sessionStorage.getItem('late-transactions') === null) sessionStorage.setItem('late-transactions', 'true')">
<div class="mt-3 mb-1 w-full border-b border-b-error/50 transactions-divider-title cursor-pointer">
<a class="no-underline inline-block w-full text-error font-semibold"
role="button"
@click="open = !open; sessionStorage.setItem('late-transactions', open)"
:aria-expanded="open">
<i class="fa-solid fa-circle-exclamation me-1"></i>{% translate "late" %}
</a>
</div>
<div class="transactions-divider-collapse overflow-visible isolation-auto"
x-show="open"
x-collapse>
<div class="flex flex-col">
{% for transaction in late_transactions %}
<c-transaction.item
:transaction="transaction"></c-transaction.item>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% for x in transactions_by_date %}
<div id="{{ x.grouper|slugify }}" class="transactions-divider"
_="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
x-data="{ open: sessionStorage.getItem('{{ x.grouper|slugify }}') !== 'false' }"
x-init="if (sessionStorage.getItem('{{ x.grouper|slugify }}') === null) sessionStorage.setItem('{{ x.grouper|slugify }}', 'true')">
<div class="mt-3 mb-1 w-full border-b border-b-base-content/30 transactions-divider-title cursor-pointer">
<a class="no-underline inline-block w-full"
role="button"
data-bs-toggle="collapse"
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
id="c-{{ x.grouper|slugify }}-collapsible"
aria-expanded="false"
aria-controls="c-{{ x.grouper|slugify }}-collapse">
@click="open = !open; sessionStorage.setItem('{{ x.grouper|slugify }}', open)"
:aria-expanded="open">
{{ x.grouper }}
</a>
</div>
<div class="bs collapse transactions-divider-collapse overflow-visible isolation-auto" id="c-{{ x.grouper|slugify }}-collapse"
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
on htmx:afterSettle from #transactions or toggle
set state to sessionStorage.getItem(the closest parent @id)
if state is 'true' or state is null
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
else
remove .show from me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
end
on show
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
<div class="transactions-divider-collapse overflow-visible isolation-auto"
x-show="open"
x-collapse>
<div class="flex flex-col">
{% for transaction in x.list %}
<c-transaction.item
@@ -42,10 +53,13 @@
</div>
{% empty %}
{% if not late_transactions %}
<c-msg.empty
title="{% translate 'No transactions this month' %}"
subtitle="{% translate "Try adding one" %}"></c-msg.empty>
{% endif %}
{% endfor %}
{# Floating bar #}
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
</div>

View File

@@ -1,6 +1,7 @@
{% load i18n %}
{% load currency_display %}
<div class="grid grid-cols-1 gap-4 mt-1 mb-3">
{% if not has_active_filter %}
{# Daily Spending#}
<div>
<c-ui.info-card color="yellow" icon="fa-solid fa-calendar-day" title="{% trans 'Daily Spending Allowance' %}" help_text={% trans "This is the final total divided by the remaining days in the month" %}>
@@ -34,6 +35,7 @@
</div>
</c-ui.info-card>
</div>
{% endif %}
{# Income#}
<div>
<c-ui.info-card color="green" icon="fa-solid fa-arrow-right-to-bracket" title="{% trans 'Income' %}">

View File

@@ -50,12 +50,13 @@
role="tab"
{% if summary_tab == 'summary' or not summary_tab %}checked="checked"{% endif %}
_="on click fetch {% url 'monthly_summary_select' selected='summary' %}"
aria-controls="summary-tab-pane" />
aria-controls="summary-tab-pane"/>
<div class="tab-content" id="summary-tab-pane" role="tabpanel">
<div id="summary"
hx-get="{% url 'monthly_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
hx-trigger="load, updated from:window, selective_update from:window, every 10m"
hx-include="#filter">
</div>
</div>
@@ -68,7 +69,8 @@
<div id="currency-summary"
hx-get="{% url 'monthly_currency_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
hx-trigger="load, updated from:window, selective_update from:window, every 10m"
hx-include="#filter">
</div>
</div>
@@ -81,7 +83,8 @@
<div id="account-summary"
hx-get="{% url 'monthly_account_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
hx-trigger="load, updated from:window, selective_update from:window, every 10m"
hx-include="#filter">
</div>
</div>
</div>
@@ -89,7 +92,7 @@
</div>
<div class="col-12 lg:col-8 lg:order-first! order-last!">
<div class="my-3">
<div class="my-3" x-data="{ filterOpen: false }" hx-preserve id="filter-container">
{# Hidden select to hold the order value and preserve the original update trigger #}
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
@@ -100,11 +103,112 @@
{# Main control bar with filter, search, and ordering #}
<div class="join w-full">
<button class="btn btn-secondary join-item relative" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
title="{% translate 'Filter transactions' %}">
<button class="btn btn-secondary join-item relative z-1" type="button"
@click="filterOpen = !filterOpen"
:aria-expanded="filterOpen" id="filter-button"
title="{% translate 'Filter transactions' %}"
_="on load or change from #filter
-- Check if any filter has a non-default value
set hasActiveFilter to false
-- Check type (default is both IN and EX checked)
set typeInputs to <input[name='type']:checked/> in #filter
if typeInputs.length is not 2
set hasActiveFilter to true
end
-- Check is_paid (default is both 1 and 0 checked)
set isPaidInputs to <input[name='is_paid']:checked/> in #filter
if isPaidInputs.length is not 2
set hasActiveFilter to true
end
-- Check mute_status (default is both active and muted checked)
set muteStatusInputs to <input[name='mute_status']:checked/> in #filter
if muteStatusInputs.length is not 2
set hasActiveFilter to true
end
-- Check description
set descInput to #id_description
if descInput exists and descInput.value is not ''
set hasActiveFilter to true
end
-- Check date_start
set dateStartInput to #id_date_start
if dateStartInput exists and dateStartInput.value is not ''
set hasActiveFilter to true
end
-- Check date_end
set dateEndInput to #id_date_end
if dateEndInput exists and dateEndInput.value is not ''
set hasActiveFilter to true
end
-- Check reference_date_start
set refDateStartInput to #id_reference_date_start
if refDateStartInput exists and refDateStartInput.value is not ''
set hasActiveFilter to true
end
-- Check reference_date_end
set refDateEndInput to #id_reference_date_end
if refDateEndInput exists and refDateEndInput.value is not ''
set hasActiveFilter to true
end
-- Check from_amount
set fromAmountInput to #id_from_amount
if fromAmountInput exists and fromAmountInput.value is not ''
set hasActiveFilter to true
end
-- Check to_amount
set toAmountInput to #id_to_amount
if toAmountInput exists and toAmountInput.value is not ''
set hasActiveFilter to true
end
-- Check account (TomSelect stores values differently)
set accountInput to #id_account
if accountInput exists and accountInput.value is not ''
set hasActiveFilter to true
end
-- Check currency
set currencyInput to #id_currency
if currencyInput exists and currencyInput.value is not ''
set hasActiveFilter to true
end
-- Check category
set categoryInput to #id_category
if categoryInput exists and categoryInput.value is not ''
set hasActiveFilter to true
end
-- Check tags
set tagsInput to #id_tags
if tagsInput exists and tagsInput.value is not ''
set hasActiveFilter to true
end
-- Check entities
set entitiesInput to #id_entities
if entitiesInput exists and entitiesInput.value is not ''
set hasActiveFilter to true
end
-- Show or hide the indicator
if hasActiveFilter
remove .hidden from #filter-active-indicator
else
add .hidden to #filter-active-indicator
end">
<i class="fa-solid fa-filter fa-fw"></i>
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full hidden z-10"></span>
</button>
{# Search box #}
@@ -113,7 +217,6 @@
<input type="search"
class="input input-bordered join-item flex-1"
placeholder="{% translate 'Search' %}"
hx-preserve
id="quick-search"
_="on input or search or htmx:afterSwap from window
if my value is empty
@@ -165,7 +268,7 @@
</div>
{# Filter transactions form #}
<div class="bs collapse z-1" id="collapse-filter" hx-preserve>
<div class="z-1" x-show="filterOpen" x-collapse>
<div class="card card-body bg-base-200 mt-2">
<div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit"

View File

@@ -64,13 +64,13 @@
</div>
</td>
<td class="table-col-auto">
<a class="no-underline"
<a class="no-underline cursor-pointer"
role="button"
data-tippy-content="
{% if rule.active %}{% translate "Deactivate" %}{% else %}{% translate "Activate" %}{% endif %}"
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}">
{% if rule.active %}<i class="fa-solid fa-toggle-on text-green-400"></i>{% else %}
<i class="fa-solid fa-toggle-off text-red-400"></i>{% endif %}
{% if rule.active %}<i class="fa-solid fa-toggle-on text-success"></i>{% else %}
<i class="fa-solid fa-toggle-off text-error"></i>{% endif %}
</a>
</td>
<td class="table-col-auto text-center">

View File

@@ -110,8 +110,7 @@
</div>
{% endfor %}
</div>
<hr class="hr my-5">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2 mt-5">
<div class="dropdown">
<button class="btn btn-secondary w-full" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
@@ -138,7 +137,7 @@
<div class="dropdown">
<button class="btn btn-primary w-full" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new action' %}
</button>
<ul class="dropdown-menu menu">
<li><a role="link" href="#"

View File

@@ -3,35 +3,46 @@
{% regroup page_obj by date|customnaturaldate as transactions_by_date %}
<div id="transactions-list" class="show-loading">
{% if late_transactions %}
<div id="late-transactions" class="transactions-divider"
x-data="{ open: sessionStorage.getItem('late-transactions') !== 'false' }"
x-init="if (sessionStorage.getItem('late-transactions') === null) sessionStorage.setItem('late-transactions', 'true')">
<div class="mt-3 mb-1 w-full border-b border-b-error/50 transactions-divider-title cursor-pointer">
<a class="no-underline inline-block w-full text-error font-semibold"
role="button"
@click="open = !open; sessionStorage.setItem('late-transactions', open)"
:aria-expanded="open">
<i class="fa-solid fa-circle-exclamation me-1"></i>{% translate "late" %}
</a>
</div>
<div class="transactions-divider-collapse overflow-visible isolation-auto"
x-show="open"
x-collapse>
<div class="flex flex-col">
{% for transaction in late_transactions %}
<c-transaction.item
:transaction="transaction"></c-transaction.item>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% for x in transactions_by_date %}
<div id="{{ x.grouper|slugify }}" class="transactions-divider"
_="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
x-data="{ open: sessionStorage.getItem('{{ x.grouper|slugify }}') !== 'false' }"
x-init="if (sessionStorage.getItem('{{ x.grouper|slugify }}') === null) sessionStorage.setItem('{{ x.grouper|slugify }}', 'true')">
<div class="mt-3 mb-1 w-full border-b border-b-base-content/30 transactions-divider-title cursor-pointer">
<a class="no-underline inline-block w-full"
role="button"
data-bs-toggle="collapse"
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
id="c-{{ x.grouper|slugify }}-collapsible"
aria-expanded="false"
aria-controls="c-{{ x.grouper|slugify }}-collapse">
@click="open = !open; sessionStorage.setItem('{{ x.grouper|slugify }}', open)"
:aria-expanded="open">
{{ x.grouper }}
</a>
</div>
<div class="bs collapse transactions-divider-collapse overflow-visible isolation-auto" id="c-{{ x.grouper|slugify }}-collapse"
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
on htmx:afterSettle from #transactions or toggle
set state to sessionStorage.getItem(the closest parent @id)
if state is 'true' or state is null
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
else
remove .show from me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
end
on show
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
<div class="transactions-divider-collapse overflow-visible isolation-auto"
x-show="open"
x-collapse>
<div class="flex flex-col">
{% for transaction in x.list %}
<c-transaction.item
@@ -42,9 +53,11 @@
</div>
{% empty %}
{% if not late_transactions %}
<c-msg.empty
title="{% translate "No transactions found" %}"
subtitle="{% translate "Try adding one" %}"></c-msg.empty>
{% endif %}
{% endfor %}
{# Floating bar #}

View File

@@ -41,7 +41,7 @@
</div>
<div class="col-12 lg:col-8 lg:order-first! order-last!">
<div>
<div x-data="{ filterOpen: false }" hx-preserve id="filter-container">
{# Hidden select to hold the order value and preserve the original update trigger #}
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
@@ -52,11 +52,112 @@
{# Main control bar with filter, search, and ordering #}
<div class="join w-full">
<button class="btn btn-secondary join-item relative" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
title="{% translate 'Filter transactions' %}">
<button class="btn btn-secondary join-item relative z-1" type="button"
@click="filterOpen = !filterOpen"
:aria-expanded="filterOpen" id="filter-button"
title="{% translate 'Filter transactions' %}"
_="on load or change from #filter
-- Check if any filter has a non-default value
set hasActiveFilter to false
-- Check type (default is both IN and EX checked)
set typeInputs to <input[name='type']:checked/> in #filter
if typeInputs.length is not 2
set hasActiveFilter to true
end
-- Check is_paid (default is both 1 and 0 checked)
set isPaidInputs to <input[name='is_paid']:checked/> in #filter
if isPaidInputs.length is not 2
set hasActiveFilter to true
end
-- Check mute_status (default is both active and muted checked)
set muteStatusInputs to <input[name='mute_status']:checked/> in #filter
if muteStatusInputs.length is not 2
set hasActiveFilter to true
end
-- Check description
set descInput to #id_description
if descInput exists and descInput.value is not ''
set hasActiveFilter to true
end
-- Check date_start
set dateStartInput to #id_date_start
if dateStartInput exists and dateStartInput.value is not ''
set hasActiveFilter to true
end
-- Check date_end
set dateEndInput to #id_date_end
if dateEndInput exists and dateEndInput.value is not ''
set hasActiveFilter to true
end
-- Check reference_date_start
set refDateStartInput to #id_reference_date_start
if refDateStartInput exists and refDateStartInput.value is not ''
set hasActiveFilter to true
end
-- Check reference_date_end
set refDateEndInput to #id_reference_date_end
if refDateEndInput exists and refDateEndInput.value is not ''
set hasActiveFilter to true
end
-- Check from_amount
set fromAmountInput to #id_from_amount
if fromAmountInput exists and fromAmountInput.value is not ''
set hasActiveFilter to true
end
-- Check to_amount
set toAmountInput to #id_to_amount
if toAmountInput exists and toAmountInput.value is not ''
set hasActiveFilter to true
end
-- Check account (TomSelect stores values differently)
set accountInput to #id_account
if accountInput exists and accountInput.value is not ''
set hasActiveFilter to true
end
-- Check currency
set currencyInput to #id_currency
if currencyInput exists and currencyInput.value is not ''
set hasActiveFilter to true
end
-- Check category
set categoryInput to #id_category
if categoryInput exists and categoryInput.value is not ''
set hasActiveFilter to true
end
-- Check tags
set tagsInput to #id_tags
if tagsInput exists and tagsInput.value is not ''
set hasActiveFilter to true
end
-- Check entities
set entitiesInput to #id_entities
if entitiesInput exists and entitiesInput.value is not ''
set hasActiveFilter to true
end
-- Show or hide the indicator
if hasActiveFilter
remove .hidden from #filter-active-indicator
else
add .hidden to #filter-active-indicator
end">
<i class="fa-solid fa-filter fa-fw"></i>
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full hidden z-10"></span>
</button>
{# Search box #}
@@ -65,7 +166,6 @@
<input type="search"
class="input input-bordered join-item flex-1"
placeholder="{% translate 'Search' %}"
hx-preserve
id="quick-search"
_="on input or search or htmx:afterSwap from window
if my value is empty
@@ -118,7 +218,7 @@
</div>
{# Filter transactions form #}
<div class="bs collapse z-1" id="collapse-filter" hx-preserve>
<div class="z-1" x-show="filterOpen" x-collapse>
<div class="card card-body bg-base-200 mt-2">
<div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit"

View File

@@ -74,5 +74,6 @@ RUN chown -R app:app /usr/src/app && \
USER app
RUN python manage.py compilemessages --settings "WYGIWYH.settings"
RUN python manage.py collectstatic --noinput
CMD ["/start-single"]

View File

@@ -10,7 +10,6 @@ INTERNAL_PORT=${INTERNAL_PORT:-8000}
# Remove flag file if it exists from previous run
rm -f /tmp/migrations_complete
python manage.py collectstatic --noinput
python manage.py migrate
# Create flag file to signal migrations are complete

View File

@@ -2,7 +2,6 @@ import './_tooltip.js';
import 'bootstrap/js/dist/dropdown';
import Toast from 'bootstrap/js/dist/toast';
import 'bootstrap/js/dist/dropdown';
import 'bootstrap/js/dist/collapse';
import Offcanvas from 'bootstrap/js/dist/offcanvas';
window.Offcanvas = Offcanvas;

View File

@@ -1,5 +1,5 @@
import AirDatepicker from 'air-datepicker';
import {createPopper} from '@popperjs/core';
import { createPopper } from '@popperjs/core';
import '../styles/_datepicker.scss'
// --- Static Locale Imports ---
@@ -40,58 +40,58 @@ import localeZh from 'air-datepicker/locale/zh.js';
// Map language codes to their imported locale objects
const allLocales = {
'ar': localeAr,
'bg': localeBg,
'ca': localeCa,
'cs': localeCs,
'da': localeDa,
'de': localeDe,
'el': localeEl,
'en': localeEn,
'es': localeEs,
'eu': localeEu,
'fi': localeFi,
'fr': localeFr,
'hr': localeHr,
'hu': localeHu,
'id': localeId,
'it': localeIt,
'ja': localeJa,
'ko': localeKo,
'nb': localeNb,
'nl': localeNl,
'pl': localePl,
'pt-BR': localePtBr,
'pt': localePt,
'ro': localeRo,
'ru': localeRu,
'si': localeSi,
'sk': localeSk,
'sl': localeSl,
'sv': localeSv,
'th': localeTh,
'tr': localeTr,
'uk': localeUk,
'zh': localeZh
'ar': localeAr,
'bg': localeBg,
'ca': localeCa,
'cs': localeCs,
'da': localeDa,
'de': localeDe,
'el': localeEl,
'en': localeEn,
'es': localeEs,
'eu': localeEu,
'fi': localeFi,
'fr': localeFr,
'hr': localeHr,
'hu': localeHu,
'id': localeId,
'it': localeIt,
'ja': localeJa,
'ko': localeKo,
'nb': localeNb,
'nl': localeNl,
'pl': localePl,
'pt-BR': localePtBr,
'pt': localePt,
'ro': localeRo,
'ru': localeRu,
'si': localeSi,
'sk': localeSk,
'sl': localeSl,
'sv': localeSv,
'th': localeTh,
'tr': localeTr,
'uk': localeUk,
'zh': localeZh
};
// --- End of Locale Imports ---
/**
 * Selects a pre-imported language file from the locale map.
 *
 * @param {string} langCode - The two-letter language code (e.g., 'en', 'es').
 * @returns {Promise<object>} A promise that resolves with the locale object.
 */
* Selects a pre-imported language file from the locale map.
*
* @param {string} langCode - The two-letter language code (e.g., 'en', 'es').
* @returns {Promise<object>} A promise that resolves with the locale object.
*/
export const getLocale = async (langCode) => {
const locale = allLocales[langCode];
const locale = allLocales[langCode];
if (locale) {
return locale;
}
if (locale) {
return locale;
}
console.warn(`Could not find locale for '${langCode}'. Defaulting to English.`);
return allLocales['en']; // Default to English
console.warn(`Could not find locale for '${langCode}'. Defaulting to English.`);
return allLocales['en']; // Default to English
};
function isMobileDevice() {
@@ -112,7 +112,7 @@ window.DatePicker = async function createDynamicDatePicker(element) {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.selectDate(date, { updateTime: true });
dp.setViewDate(date);
}
};
@@ -126,16 +126,18 @@ window.DatePicker = async function createDynamicDatePicker(element) {
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: await getLocale(element.dataset.language),
onSelect: ({date, formattedDate, datepicker}) => {
onSelect: ({ date, formattedDate, datepicker }) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
// Store popper instance for updating on view changes
let popperInstance = null;
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
position({ $datepicker, $target, $pointer, done }) {
popperInstance = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
@@ -157,16 +159,24 @@ window.DatePicker = async function createDynamicDatePicker(element) {
options: {
element: $pointer
}
}
}
]
});
return function completeHide() {
popper.destroy();
popperInstance.destroy();
popperInstance = null;
done();
};
},
onChangeView() {
// Update popper position when view changes (e.g., clicking year)
// Use setTimeout to allow the DOM to update before recalculating
if (popperInstance) {
setTimeout(() => popperInstance.update(), 0);
}
}
} : {};
let opts = {...baseOpts, ...positionConfig};
let opts = { ...baseOpts, ...positionConfig };
if (element.dataset.value) {
opts["selectedDates"] = [element.dataset.value];
opts["startDate"] = [element.dataset.value];
@@ -179,7 +189,7 @@ window.MonthYearPicker = async function createDynamicDatePicker(element) {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.selectDate(date, { updateTime: true });
dp.setViewDate(date);
}
};
@@ -193,16 +203,18 @@ window.MonthYearPicker = async function createDynamicDatePicker(element) {
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: await getLocale(element.dataset.language),
onSelect: ({date, formattedDate, datepicker}) => {
onSelect: ({ date, formattedDate, datepicker }) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
// Store popper instance for updating on view changes
let popperInstance = null;
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
position({ $datepicker, $target, $pointer, done }) {
popperInstance = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
@@ -228,12 +240,19 @@ window.MonthYearPicker = async function createDynamicDatePicker(element) {
]
});
return function completeHide() {
popper.destroy();
popperInstance.destroy();
popperInstance = null;
done();
};
},
onChangeView() {
// Update popper position when view changes (e.g., clicking year)
if (popperInstance) {
setTimeout(() => popperInstance.update(), 0);
}
}
} : {};
let opts = {...baseOpts, ...positionConfig};
let opts = { ...baseOpts, ...positionConfig };
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];
@@ -246,7 +265,7 @@ window.YearPicker = async function createDynamicDatePicker(element) {
content: element.dataset.nowButtonTxt,
onClick: (dp) => {
let date = new Date();
dp.selectDate(date, {updateTime: true});
dp.selectDate(date, { updateTime: true });
dp.setViewDate(date);
}
};
@@ -260,16 +279,18 @@ window.YearPicker = async function createDynamicDatePicker(element) {
autoClose: element.dataset.autoClose === 'true',
buttons: element.dataset.clearButton === 'true' ? ['clear', todayButton] : [todayButton],
locale: await getLocale(element.dataset.language),
onSelect: ({date, formattedDate, datepicker}) => {
onSelect: ({ date, formattedDate, datepicker }) => {
const _event = new CustomEvent("change", {
bubbles: true,
});
datepicker.$el.dispatchEvent(_event);
}
};
// Store popper instance for updating on view changes
let popperInstance = null;
const positionConfig = !isOnMobile ? {
position({$datepicker, $target, $pointer, done}) {
let popper = createPopper($target, $datepicker, {
position({ $datepicker, $target, $pointer, done }) {
popperInstance = createPopper($target, $datepicker, {
placement: 'bottom',
modifiers: [
{
@@ -295,12 +316,19 @@ window.YearPicker = async function createDynamicDatePicker(element) {
]
});
return function completeHide() {
popper.destroy();
popperInstance.destroy();
popperInstance = null;
done();
};
},
onChangeView() {
// Update popper position when view changes (e.g., clicking year)
if (popperInstance) {
setTimeout(() => popperInstance.update(), 0);
}
}
} : {};
let opts = {...baseOpts, ...positionConfig};
let opts = { ...baseOpts, ...positionConfig };
if (element.dataset.value) {
opts["selectedDates"] = [new Date(element.dataset.value + "T00:00:00")];
opts["startDate"] = [new Date(element.dataset.value + "T00:00:00")];

View File

@@ -56,6 +56,22 @@
animation: slide-in-left 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}
@keyframes slide-out-left {
0% {
transform: translateX(0);
opacity: 1;
}
100% {
transform: translateX(-100%);
opacity: 0;
}
}
.slide-out-left {
animation: slide-out-left 0.15s cubic-bezier(0.55, 0.085, 0.68, 0.53) both;
}
// HTMX Loading
@keyframes spin {
0% {
@@ -248,6 +264,32 @@
}
}
/**
* ----------------------------------------
* animation slide-in-bottom-short
* A variant with smaller translateY for elements at bottom of viewport
* ----------------------------------------
*/
@keyframes slide-in-bottom-short {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.slide-in-bottom-short {
animation: slide-in-bottom-short 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
}
.slide-in-bottom-short-reverse {
animation: slide-in-bottom-short 0.3s cubic-bezier(0.250, 0.460, 0.450, 0.940) reverse both;
}
@keyframes disable-pointer-events {
0%,

View File

@@ -323,4 +323,4 @@ $breakpoints: (
.offcanvas-size-sm {
--offcanvas-width: min(95vw, 250px);
}
}

View File

@@ -6,8 +6,6 @@ $enable-transitions: true !default;
$enable-reduced-motion: true !default;
$transition-fade: opacity 0.15s linear !default;
$transition-collapse: height 0.35s ease !default;
$transition-collapse-width: width 0.35s ease !default;
// Fade transition
.fade {
@@ -22,35 +20,4 @@ $transition-collapse-width: width 0.35s ease !default;
&:not(.show) {
opacity: 0;
}
}
// // Collapse transitions
.bs.collapse {
&:not(.show) {
display: none;
}
}
.bs.collapsing {
height: 0;
overflow: hidden;
transition: $transition-collapse;
@if $enable-reduced-motion {
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&.collapse-horizontal {
width: 0;
height: auto;
transition: $transition-collapse-width;
@if $enable-reduced-motion {
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
}

View File

@@ -45,14 +45,6 @@ select {
font-size: 14px;
}
[data-bs-toggle="collapse"] .fa-chevron-down {
transition: transform 0.25s ease-in-out;
}
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
transform: rotate(-180deg);
}
div:where(.swal2-container) {
z-index: 1101 !important;
}
@@ -85,4 +77,4 @@ div:where(.swal2-container) {
[x-cloak] {
display: none !important;
}
}

View File

@@ -309,13 +309,15 @@
}
.sidebar-fixed {
/* Sets the fixed, expanded width for the container */
@apply lg:w-[17%] transition-all duration-100;
/* Sets the fixed, expanded width for the container.
Using fixed rem width instead of percentage to prevent width inconsistencies
caused by scrollbar presence affecting viewport width calculations. */
@apply lg:w-80 transition-all duration-100;
}
.sidebar-fixed #sidebar {
/* Sets the fixed, expanded width for the inner navigation */
@apply lg:w-[17%] transition-all duration-100;
@apply lg:w-80 transition-all duration-100;
}
.sidebar-fixed .sidebar-item-list {
@@ -324,9 +326,7 @@
.sidebar-fixed + main {
/* Adjusts the main content margin to account for the expanded sidebar */
@apply lg:ml-[17%];
/* Using 16vw to account for padding/margins */
@apply lg:ml-80;
}
.sidebar-fixed .sidebar-item {