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 import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Sum, Avg from django.db.models import Sum, Avg
@@ -234,7 +233,7 @@ def strategy_entry_add(request, strategy_id):
if request.method == "POST": if request.method == "POST":
form = DCAEntryForm(request.POST, strategy=strategy) form = DCAEntryForm(request.POST, strategy=strategy)
if form.is_valid(): if form.is_valid():
entry = form.save() form.save()
messages.success(request, _("Entry added successfully")) messages.success(request, _("Entry added successfully"))
return HttpResponse( return HttpResponse(

View File

@@ -2,7 +2,8 @@ from django.contrib.auth.decorators import login_required
from django.db.models import ( from django.db.models import (
Q, Q,
) )
from django.http import HttpResponse from django.http import HttpResponse, Http404
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_http_methods 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") summary_tab = request.session.get("monthly_summary_tab", "summary")
if month < 1 or month > 12: if month < 1 or month > 12:
from django.http import Http404
raise Http404("Month is out of range") raise Http404("Month is out of range")
next_month = 1 if month == 12 else month + 1 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"]) @require_http_methods(["GET"])
def monthly_summary(request, month: int, year: int): def monthly_summary(request, month: int, year: int):
# Base queryset with all required filters # Base queryset with all required filters
base_queryset = ( base_queryset = Transaction.objects.filter(
Transaction.objects.filter( reference_date__year=year,
reference_date__year=year, reference_date__month=month,
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())
) )
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) percentages = calculate_percentage_distribution(data)
context = { context = {
@@ -132,6 +162,7 @@ def monthly_summary(request, month: int, year: int):
currency_totals=data, month=month, year=year currency_totals=data, month=month, year=year
), ),
"percentages": percentages, "percentages": percentages,
"has_active_filter": has_active_filter,
} }
return render( return render(
@@ -149,9 +180,38 @@ def monthly_account_summary(request, month: int, year: int):
base_queryset = Transaction.objects.filter( base_queryset = Transaction.objects.filter(
reference_date__year=year, reference_date__year=year,
reference_date__month=month, 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) account_percentages = calculate_percentage_distribution(account_data)
context = { context = {
@@ -171,16 +231,41 @@ def monthly_account_summary(request, month: int, year: int):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def monthly_currency_summary(request, month: int, year: int): def monthly_currency_summary(request, month: int, year: int):
# Base queryset with all required filters # Base queryset with all required filters
base_queryset = ( base_queryset = Transaction.objects.filter(
Transaction.objects.filter( reference_date__year=year,
reference_date__year=year, reference_date__month=month,
reference_date__month=month,
)
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
.exclude(account__in=request.user.untracked_accounts.all())
) )
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) currency_percentages = calculate_percentage_distribution(currency_data)
context = { context = {

View File

@@ -1,6 +1,7 @@
{% load i18n %} {% load i18n %}
{% load currency_display %} {% load currency_display %}
<div class="grid grid-cols-1 gap-4 mt-1 mb-3"> <div class="grid grid-cols-1 gap-4 mt-1 mb-3">
{% if not has_active_filter %}
{# Daily Spending#} {# Daily Spending#}
<div> <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" %}> <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> </div>
</c-ui.info-card> </c-ui.info-card>
</div> </div>
{% endif %}
{# Income#} {# Income#}
<div> <div>
<c-ui.info-card color="green" icon="fa-solid fa-arrow-right-to-bracket" title="{% trans 'Income' %}"> <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" role="tab"
{% if summary_tab == 'summary' or not summary_tab %}checked="checked"{% endif %} {% if summary_tab == 'summary' or not summary_tab %}checked="checked"{% endif %}
_="on click fetch {% url 'monthly_summary_select' selected='summary' %}" _="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 class="tab-content" id="summary-tab-pane" role="tabpanel">
<div id="summary" <div id="summary"
hx-get="{% url 'monthly_summary' month=month year=year %}" hx-get="{% url 'monthly_summary' month=month year=year %}"
class="show-loading" 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> </div>
@@ -68,7 +69,8 @@
<div id="currency-summary" <div id="currency-summary"
hx-get="{% url 'monthly_currency_summary' month=month year=year %}" hx-get="{% url 'monthly_currency_summary' month=month year=year %}"
class="show-loading" 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> </div>
@@ -81,7 +83,8 @@
<div id="account-summary" <div id="account-summary"
hx-get="{% url 'monthly_account_summary' month=month year=year %}" hx-get="{% url 'monthly_account_summary' month=month year=year %}"
class="show-loading" 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> </div>
</div> </div>
@@ -100,11 +103,112 @@
{# Main control bar with filter, search, and ordering #} {# Main control bar with filter, search, and ordering #}
<div class="join w-full"> <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" @click="filterOpen = !filterOpen"
:aria-expanded="filterOpen" id="filter-button" :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> <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> </button>
{# Search box #} {# Search box #}

View File

@@ -52,11 +52,112 @@
{# Main control bar with filter, search, and ordering #} {# Main control bar with filter, search, and ordering #}
<div class="join w-full"> <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" @click="filterOpen = !filterOpen"
:aria-expanded="filterOpen" id="filter-button" :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> <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> </button>
{# Search box #} {# Search box #}