feat(transactions:filter): make montlhy summary filter-aware

This commit is contained in:
Herculino Trotta
2025-12-28 13:20:25 -03:00
parent a2871d5289
commit 01f91352d6
5 changed files with 323 additions and 32 deletions

View File

@@ -1,4 +1,3 @@
# apps/dca_tracker/views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Avg
@@ -234,7 +233,7 @@ def strategy_entry_add(request, strategy_id):
if request.method == "POST":
form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid():
entry = form.save()
form.save()
messages.success(request, _("Entry added successfully"))
return HttpResponse(

View File

@@ -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
@@ -107,17 +106,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 +162,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 +180,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 +231,41 @@ def monthly_account_summary(request, month: int, year: int):
@require_http_methods(["GET"])
def monthly_currency_summary(request, month: int, year: int):
# Base queryset with all required filters
base_queryset = (
Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
base_queryset = Transaction.objects.filter(
reference_date__year=year,
reference_date__month=month,
)
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
# Apply filters and check if any are active
f = TransactionsFilter(request.GET, queryset=base_queryset)
# Check if any filter has a non-default value
has_active_filter = False
if f.form.is_valid():
for name, value in f.form.cleaned_data.items():
if not value:
continue
if name == "type" and set(value) == {"IN", "EX"}:
continue
if name == "is_paid" and set(value) == {"1", "0"}:
continue
if name == "mute_status" and set(value) == {"active", "muted"}:
continue
has_active_filter = True
break
if has_active_filter:
queryset = f.qs
else:
queryset = (
base_queryset.exclude(
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
)
.exclude(account__in=request.user.untracked_accounts.all())
.exclude(account__is_asset=True)
)
currency_data = calculate_currency_totals(queryset.all(), ignore_empty=True)
currency_percentages = calculate_percentage_distribution(currency_data)
context = {

View File

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

View File

@@ -50,12 +50,13 @@
role="tab"
{% if summary_tab == 'summary' or not summary_tab %}checked="checked"{% endif %}
_="on click fetch {% url 'monthly_summary_select' selected='summary' %}"
aria-controls="summary-tab-pane" />
aria-controls="summary-tab-pane"/>
<div class="tab-content" id="summary-tab-pane" role="tabpanel">
<div id="summary"
hx-get="{% url 'monthly_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
hx-trigger="load, updated from:window, selective_update from:window, every 10m"
hx-include="#filter">
</div>
</div>
@@ -68,7 +69,8 @@
<div id="currency-summary"
hx-get="{% url 'monthly_currency_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
hx-trigger="load, updated from:window, selective_update from:window, every 10m"
hx-include="#filter">
</div>
</div>
@@ -81,7 +83,8 @@
<div id="account-summary"
hx-get="{% url 'monthly_account_summary' month=month year=year %}"
class="show-loading"
hx-trigger="load, updated from:window, selective_update from:window, every 10m">
hx-trigger="load, updated from:window, selective_update from:window, every 10m"
hx-include="#filter">
</div>
</div>
</div>
@@ -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"
<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' %}">
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 #}

View File

@@ -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"
<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' %}">
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 #}