Implement filters and sorting for transactions

Close 
This commit is contained in:
Edgar P. Burkhart 2025-01-04 18:28:37 +01:00
parent 7851e8afbb
commit b848bf8d65
Signed by: edpibu
GPG key ID: 9833D3C5A25BD227
10 changed files with 297 additions and 124 deletions
nummi

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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;
}
}
});
}

View file

@ -1,13 +1,13 @@
{% load i18n main_extras %}
{% if first.show %}
<a href="?page=1" class="first">1</a>
<a href="?{% page_url 1 %}" class="first">1</a>
{% if first.dots %}<span></span>{% endif %}
{% endif %}
{% for page in pages %}
<a href="?page={{ page.number }}"
<a href="?{% page_url page.number %}"
{% if page.current %}class="cur"{% endif %}>{{ page.number }}</a>
{% endfor %}
{% if last.show %}
{% if last.dots %}<span></span>{% endif %}
<a href="?page={{ last.number }}" class="last">{{ last.number }}</a>
<a href="?{% page_url last.number %}" class="last">{{ last.number }}</a>
{% endif %}

View file

@ -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()

View file

@ -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")
}
),
}

View file

@ -0,0 +1,19 @@
{% load i18n %}
<details {% if filters %}open{% endif %}>
<summary>{% translate "Filters" %}</summary>
<form class="filter" method="get">
{% for field in form %}
<div class="field">
<label>{{ field.label }}</label>
{{ field }}
</div>
{% endfor %}
<div class="buttons">
<input type="submit" value="{% translate "Filter" %}">
<input type="reset" value="{% translate "Reset" %}">
{% if filters %}
<a href="?" class="del">{% translate "Clear" %}</a>
{% endif %}
</div>
</form>
</details>

View file

@ -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 @@
<p>
<a class="big-link" href="{% url "new_transaction" %}">{{ "add-circle"|remix }}{% translate "Add transaction" %}</a>
</p>
{% transaction_filters form=filter_form %}
{% transaction_table transactions %}
{% endblock table %}

View file

@ -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 | {

View file

@ -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