diff --git a/nummi/main/static/main/css/form.css b/nummi/main/static/main/css/form.css index 5685fa5..f4fd25e 100644 --- a/nummi/main/static/main/css/form.css +++ b/nummi/main/static/main/css/form.css @@ -48,141 +48,140 @@ form { border: 1px solid var(--gray); padding: var(--gap); block-size: min-content; + } + .fieldset { + display: grid; + grid-auto-columns: 1fr; + grid-auto-flow: column; + gap: inherit; + padding: 0; + margin: 0; + border: none; + } - .fieldset { - display: grid; - grid-auto-columns: 1fr; - grid-auto-flow: column; - gap: inherit; - padding: 0; - margin: 0; - border: none; - } + ul.errorlist { + color: var(--red); + font-weight: 550; + list-style: none; + padding: 0; + margin: 0; + } + + .field { + display: grid; + grid-auto-rows: min-content; + align-items: center; + column-gap: 0.5rem; ul.errorlist { - color: var(--red); - font-weight: 550; - list-style: none; - padding: 0; - margin: 0; + font-size: 0.8rem; } - .field { - display: grid; - grid-auto-rows: min-content; - align-items: center; - column-gap: 0.5rem; - - ul.errorlist { - font-size: 0.8rem; - } - - &:has(> textarea) { - grid-template-rows: min-content 1fr; - textarea { - resize: block; - } - } - &:has(> input[type="checkbox"]) { - grid-template-columns: min-content 1fr; - > label { - font-size: inherit; - grid-row: 1; - grid-column: 2; - padding: 0.5rem; - line-height: initial; - } - > input { - grid-row: 1; - grid-column: 1; - margin: 0.5rem; - } - &:has(> :focus) { - background: var(--bg-01); - } - } - - > label { - font-size: 0.8rem; - line-height: 0.8rem; - z-index: 10; - } - - > a { - padding: 0.5rem; - } - - input, - select, + &:has(> textarea) { + grid-template-rows: min-content 1fr; textarea { - font: inherit; - line-height: initial; - border: none; - border: 1px solid transparent; - border-bottom: 1px solid var(--gray); - background: none; - z-index: 1; + resize: block; + } + } + &:has(> input[type="checkbox"]) { + grid-template-columns: min-content 1fr; + > label { + font-size: inherit; + grid-row: 1; + grid-column: 2; padding: 0.5rem; + line-height: initial; + } + > input { + grid-row: 1; + grid-column: 1; + margin: 0.5rem; + } + &:has(> :focus) { + background: var(--bg-1); + } + } - &:has(~ ul.errorlist) { - border-color: var(--red); - } - &.autocompleted:not(.mod) { - border-bottom-color: var(--green); - } + > label { + font-size: 0.8rem; + line-height: 0.8rem; + z-index: 10; + } - &:not([type="checkbox"]) { - width: 100%; - margin: 0; - } + > a { + padding: 0.5rem; + } - &[name*="value"] { - text-align: right; - font-feature-settings: var(--num); - } - &[name*="date"] { - font-feature-settings: var(--num); - } + input, + select, + textarea { + font: inherit; + line-height: initial; + border: none; + border: 1px solid transparent; + border-bottom: 1px solid var(--gray); + background: none; + z-index: 1; + padding: 0.5rem; - &:focus { - outline: none; - background: var(--bg-01); - } + &:has(~ ul.errorlist) { + border-color: var(--red); + } + &.autocompleted:not(.mod) { + border-bottom-color: var(--green); } - > .file-input { - display: grid; - - > .current { - display: grid; - grid-template-columns: 1fr; - grid-auto-columns: max-content; - grid-auto-flow: column; - a { - padding: 0.5rem; - } - } - - input[type="file"] { - &::file-selector-button { - display: none; - } - } + &:not([type="checkbox"]) { + width: 100%; + margin: 0; } - > .ico-input { + + &[name*="value"] { + text-align: right; + font-feature-settings: var(--num); + } + &[name*="date"] { + font-feature-settings: var(--num); + } + + &:focus { + outline: none; + background: var(--bg-1); + } + } + + > .file-input { + display: grid; + + > .current { display: grid; - grid-template-columns: min-content 1fr; - column-gap: 0.5rem; - align-items: center; - span[class|="ri"] { + grid-template-columns: 1fr; + grid-auto-columns: max-content; + grid-auto-flow: column; + a { padding: 0.5rem; } + } - &:has(> :focus) { - background: var(--bg-01); + input[type="file"] { + &::file-selector-button { + display: none; } } } + > .ico-input { + display: grid; + grid-template-columns: min-content 1fr; + column-gap: 0.5rem; + align-items: center; + span[class|="ri"] { + padding: 0.5rem; + } + + &:has(> :focus) { + background: var(--bg-1); + } + } } .buttons { grid-column: 1 / -1; diff --git a/nummi/main/static/main/css/main.css b/nummi/main/static/main/css/main.css index b0ef3b0..a3f9ed5 100644 --- a/nummi/main/static/main/css/main.css +++ b/nummi/main/static/main/css/main.css @@ -538,3 +538,22 @@ ul.statements { .value { font-feature-settings: var(--num); } + +details { + border: var(--gray) 1px solid; + margin-bottom: var(--gap); + + summary { + font-weight: 650; + cursor: pointer; + padding: var(--gap); + } + + &[open] summary { + background: var(--bg-1); + } + + form { + padding: var(--gap); + } +} diff --git a/nummi/main/static/main/js/base.js b/nummi/main/static/main/js/base.js index eaafec5..3cf2d92 100644 --- a/nummi/main/static/main/js/base.js +++ b/nummi/main/static/main/js/base.js @@ -135,3 +135,14 @@ if (accounts) { }); } } + +const filterForm = document.querySelector("form.filter"); +if (filterForm) { + filterForm.addEventListener("submit", (event) => { + for (element of filterForm.elements) { + if (element.value == "") { + element.disabled = true; + } + } + }); +} diff --git a/nummi/main/templates/main/pagination_links.html b/nummi/main/templates/main/pagination_links.html index fcd8531..e2a55b9 100644 --- a/nummi/main/templates/main/pagination_links.html +++ b/nummi/main/templates/main/pagination_links.html @@ -1,13 +1,13 @@ {% load i18n main_extras %} {% if first.show %} - 1 + 1 {% if first.dots %}{% endif %} {% endif %} {% for page in pages %} - {{ page.number }} {% endfor %} {% if last.show %} {% if last.dots %}{% endif %} - {{ last.number }} + {{ last.number }} {% endif %} diff --git a/nummi/main/templatetags/main_extras.py b/nummi/main/templatetags/main_extras.py index 9dd97e4..1b1e5dc 100644 --- a/nummi/main/templatetags/main_extras.py +++ b/nummi/main/templatetags/main_extras.py @@ -91,10 +91,11 @@ def balance(accounts): ) -@register.inclusion_tag("main/pagination_links.html") -def pagination_links(page_obj): +@register.inclusion_tag("main/pagination_links.html", takes_context=True) +def pagination_links(context, page_obj): _n = 3 return { + "request": context["request"], "pages": [ {"number": p, "current": p == page_obj.number} for p in page_obj.paginator.page_range @@ -110,3 +111,10 @@ def pagination_links(page_obj): "number": page_obj.paginator.num_pages, }, } + + +@register.simple_tag(takes_context=True) +def page_url(context, page): + query = context["request"].GET.copy() + query["page"] = page + return query.urlencode() diff --git a/nummi/transaction/forms.py b/nummi/transaction/forms.py index 807c00e..15d3dbd 100644 --- a/nummi/transaction/forms.py +++ b/nummi/transaction/forms.py @@ -3,6 +3,7 @@ import json from category.forms import CategorySelect from django import forms from django.forms import formset_factory +from django.utils.translation import gettext_lazy as _ from main.forms import DatalistInput, NummiFileInput, NummiForm from statement.forms import StatementSelect @@ -145,3 +146,55 @@ class MultipleInvoicesForm(forms.Form): InvoicesFormSet = formset_factory(MultipleInvoicesForm) + + +class DateInput(forms.DateInput): + input_type = "date" + + def __init__(self, attrs=None): + super().__init__(attrs) + self.format = "%Y-%m-%d" + + +class DateField(forms.DateField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("widget", DateInput()) + super().__init__(*args, **kwargs) + + +class TransactionFiltersForm(forms.Form): + start_date = DateField(required=False) + end_date = DateField(required=False) + category = forms.ModelChoiceField( + queryset=None, required=False, widget=CategorySelect() + ) + account = forms.ModelChoiceField(queryset=None, required=False) + search = forms.CharField(label=_("Search"), required=False) + sort_by = forms.ChoiceField( + label=_("Sort by"), + choices=[ + ("", _("Default")), + ("date", _("Date +")), + ("-date", _("Date -")), + ("value", _("Value +")), + ("-value", _("Value -")), + ], + required=False, + ) + + def __init__(self, *args, **kwargs): + _user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + self.fields["category"].queryset = _user.category_set + self.fields["account"].queryset = _user.account_set + + self.fields["category"].widget.attrs |= { + "class": "category", + "data-icons": json.dumps( + { + str(cat.id): cat.icon + for cat in self.fields["category"].queryset.only("id", "icon") + } + ), + } diff --git a/nummi/transaction/templates/transaction/transaction_filters.html b/nummi/transaction/templates/transaction/transaction_filters.html new file mode 100644 index 0000000..907a14f --- /dev/null +++ b/nummi/transaction/templates/transaction/transaction_filters.html @@ -0,0 +1,19 @@ +{% load i18n %} +
+ {% translate "Filters" %} +
+ {% for field in form %} +
+ + {{ field }} +
+ {% endfor %} +
+ + + {% if filters %} + {% translate "Clear" %} + {% endif %} +
+
+
diff --git a/nummi/transaction/templates/transaction/transaction_list.html b/nummi/transaction/templates/transaction/transaction_list.html index 0c2ca7f..afd3b7c 100644 --- a/nummi/transaction/templates/transaction/transaction_list.html +++ b/nummi/transaction/templates/transaction/transaction_list.html @@ -3,6 +3,10 @@ {% block name %} {% translate "Transactions" %} {% endblock name %} +{% block link %} + {{ block.super }} + {% css "main/css/form.css" %} +{% endblock link %} {% block h2 %} {% translate "Transactions" %} {% endblock h2 %} @@ -10,5 +14,6 @@

{{ "add-circle"|remix }}{% translate "Add transaction" %}

+ {% transaction_filters form=filter_form %} {% transaction_table transactions %} {% endblock table %} diff --git a/nummi/transaction/templatetags/transaction_extras.py b/nummi/transaction/templatetags/transaction_extras.py index e2311a0..e905a02 100644 --- a/nummi/transaction/templatetags/transaction_extras.py +++ b/nummi/transaction/templatetags/transaction_extras.py @@ -17,10 +17,8 @@ def transaction_table(context, transactions, n_max=None, **kwargs): del kwargs["transactions_url"] transactions = transactions[:n_max] - if "account" in context or "statement" in context: - kwargs.setdefault("hide_account", True) - if "category" in context: - kwargs.setdefault("hide_category", True) + kwargs.setdefault("hide_account", "account" in context or "statement" in context) + kwargs.setdefault("hide_category", "category" in context) ncol = 8 if kwargs.get("hide_account"): @@ -31,6 +29,12 @@ def transaction_table(context, transactions, n_max=None, **kwargs): return kwargs | {"transactions": transactions, "ncol": ncol} +@register.inclusion_tag("transaction/transaction_filters.html", takes_context=True) +def transaction_filters(context, **kwargs): + kwargs.setdefault("filters", context.get("filters")) + return kwargs + + @register.inclusion_tag("transaction/invoice_table.html") def invoice_table(transaction, **kwargs): return kwargs | { diff --git a/nummi/transaction/views.py b/nummi/transaction/views.py index ebf2238..41fd184 100644 --- a/nummi/transaction/views.py +++ b/nummi/transaction/views.py @@ -1,6 +1,13 @@ from account.models import Account from category.models import Category from django.contrib import messages +from django.contrib.postgres.search import ( + SearchQuery, + SearchRank, + SearchVector, + TrigramSimilarity, +) +from django.db import models from django.forms import ValidationError from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy @@ -18,7 +25,12 @@ from main.views import ( UserMixin, ) -from .forms import InvoiceForm, MultipleInvoicesForm, TransactionForm +from .forms import ( + InvoiceForm, + MultipleInvoicesForm, + TransactionFiltersForm, + TransactionForm, +) from .models import Invoice, Transaction @@ -167,6 +179,49 @@ class TransactionListView(NummiListView): context_object_name = "transactions" paginate_by = 50 + def get_queryset(self, **kwargs): + queryset = super().get_queryset(**kwargs) + + if date := self.request.GET.get("start_date"): + queryset = queryset.filter(date__gte=date) + if date := self.request.GET.get("end_date"): + queryset = queryset.filter(date__lte=date) + if category := self.request.GET.get("category"): + queryset = queryset.filter(category=category) + if account := self.request.GET.get("account"): + queryset = queryset.filter(statement__account=account) + if search := self.request.GET.get("search"): + queryset = ( + queryset.annotate( + rank=SearchRank( + SearchVector("name", weight="A") + + SearchVector("description", weight="B") + + SearchVector("trader", weight="B") + + SearchVector("category__name", weight="C"), + SearchQuery(search, search_type="websearch"), + ), + similarity=TrigramSimilarity("name", search), + ) + .filter(models.Q(rank__gte=0.1) | models.Q(similarity__gte=0.3)) + .order_by("-rank", "-date") + ) + if sort_by := self.request.GET.get("sort_by"): + queryset = queryset.order_by(sort_by) + + return queryset + + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + + filters = self.request.GET.copy() + filters.pop("page", None) + if filters: + data["filters"] = True + data["filter_form"] = TransactionFiltersForm( + initial=filters, user=self.request.user + ) + return data + class TransactionACMixin: model = Transaction