mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-01-12 04:10:29 +01:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e5492ea1 | ||
|
|
b074ef7929 | ||
|
|
ff4bd79634 | ||
|
|
dd6a390e6b | ||
|
|
b455a0251a | ||
|
|
6c90e1bb7f | ||
|
|
39f66b620a | ||
|
|
92cf526b76 | ||
|
|
700d35b5d5 | ||
|
|
01f91352d6 | ||
|
|
a2871d5289 | ||
|
|
2076903740 | ||
|
|
c7ff6db0bf | ||
|
|
05ede58c36 | ||
|
|
b0101dae1a | ||
|
|
edcad37926 | ||
|
|
94f5c25829 | ||
|
|
3dce9e1c55 | ||
|
|
0545fb7651 | ||
|
|
f918351303 | ||
|
|
8f06c06d32 | ||
|
|
67f79effab | ||
|
|
c168886968 | ||
|
|
4aa29545ec | ||
|
|
cf7d4b1404 | ||
|
|
606e6b3843 | ||
|
|
29b6ee3af3 | ||
|
|
938c128d07 | ||
|
|
dc33fda5d3 |
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
103
app/apps/common/checks.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
18
app/apps/currencies/migrations/0023_add_failure_count.py
Normal file
18
app/apps/currencies/migrations/0023_add_failure_count.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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": _(
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
app/apps/currencies/tests/__init__.py
Normal file
1
app/apps/currencies/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package for currencies app
|
||||
109
app/apps/currencies/tests/test_automatic_exchange_rates.py
Normal file
109
app/apps/currencies/tests/test_automatic_exchange_rates.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
0
app/apps/import_app/tests/__init__.py
Normal file
0
app/apps/import_app/tests/__init__.py
Normal file
275
app/apps/import_app/tests/test_import_service_v1.py
Normal file
275
app/apps/import_app/tests/test_import_service_v1.py
Normal 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)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
316
app/apps/insights/utils/month_by_month.py
Normal file
316
app/apps/insights/utils/month_by_month.py
Normal 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
|
||||
303
app/apps/insights/utils/year_by_year.py
Normal file
303
app/apps/insights/utils/year_by_year.py
Normal 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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
0
app/apps/monthly_overview/tests/__init__.py
Normal file
0
app/apps/monthly_overview/tests/__init__.py
Normal file
331
app/apps/monthly_overview/tests/test_summary.py
Normal file
331
app/apps/monthly_overview/tests/test_summary.py
Normal 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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
250
app/templates/insights/fragments/month_by_month.html
Normal file
250
app/templates/insights/fragments/month_by_month.html
Normal 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>
|
||||
204
app/templates/insights/fragments/year_by_year.html
Normal file
204
app/templates/insights/fragments/year_by_year.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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' %}">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="#"
|
||||
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
1
frontend/src/js/bootstrap.js
vendored
1
frontend/src/js/bootstrap.js
vendored
@@ -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;
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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%,
|
||||
|
||||
@@ -323,4 +323,4 @@ $breakpoints: (
|
||||
|
||||
.offcanvas-size-sm {
|
||||
--offcanvas-width: min(95vw, 250px);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user