parent
7851e8afbb
commit
b848bf8d65
10 changed files with 297 additions and 124 deletions
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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 %}
|
||||
|
|
|
@ -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 | {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue