feat: Buchungen serverseitig nach Spalte sortierbar (A11-T5)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:29:25 +02:00
co-authored by Claude Opus 5.5
parent 2a5cd141f2
commit b1e007ed15
4 changed files with 150 additions and 14 deletions
+8 -1
View File
@@ -25,7 +25,7 @@ from app.routers.planning import list_planned as _list_planned
from app.routers.planning import list_recurring as _list_recurring
from app.routers.planning import recurring_suggestions as _recurring_suggestions
from app.routers.scenarios import list_scenarios as _list_scenarios
from app.routers.transactions import count_transactions, list_transactions
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
from app.services.balances import account_balance, total_balance
from app.services.planning_chart import refresh_planning_chart
from app.services.projection_service import refresh_stale_projections
@@ -168,6 +168,8 @@ def buchungen(
q: str | None = None,
status: str = "confirmed",
page: int = 1,
sort: str = "datum",
dir: str = "asc",
session: Session = Depends(get_session),
):
# HTML-Filterformular sendet leere Strings fuer nicht ausgefuellte Felder -
@@ -179,6 +181,7 @@ def buchungen(
date_to = _opt_date(date_to)
category_id = _opt_int(category_id)
page = max(1, page)
sort, dir = normalize_sort(sort, dir)
total = count_transactions(
account_id=account_id,
date_from=date_from,
@@ -197,6 +200,8 @@ def buchungen(
category_id=category_id,
q=q,
status=status,
sort=sort,
dir=dir,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
session=session,
@@ -217,6 +222,8 @@ def buchungen(
},
"page": page,
"total_pages": total_pages,
"sort": sort,
"dir": dir,
})
+35 -1
View File
@@ -75,6 +75,38 @@ def _apply_transaction_filters(
return stmt
# Serverseitige Sortierung der Buchungsliste (Ausbaustufe 11): Whitelist
# URL-Schlüssel -> Sortierausdruck. Textspalten über lower(), damit SQLite
# (Tests) und Postgres gleich (case-insensitiv) sortieren.
SORT_COLUMNS = {
"datum": Transaction.booking_date,
"konto": func.lower(Account.name),
"zweck": func.lower(Transaction.purpose),
"empfaenger": func.lower(Transaction.counterparty),
"betrag": Transaction.amount,
"kategorie": func.lower(Category.name),
}
def normalize_sort(sort: str | None, dir: str | None) -> tuple[str, str]:
"""Unbekannte Werte fallen still auf den Standard (datum/asc) zurück -
kein 422 für manipulierte oder veraltete Links."""
return (sort if sort in SORT_COLUMNS else "datum",
dir if dir in ("asc", "desc") else "asc")
def _apply_sort(stmt, sort: str | None, dir: str | None):
sort, dir = normalize_sort(sort, dir)
if sort == "konto":
stmt = stmt.outerjoin(Account, Account.id == Transaction.account_id)
elif sort == "kategorie":
stmt = stmt.outerjoin(Category, Category.id == Transaction.category_id)
col = SORT_COLUMNS[sort]
if dir == "desc":
return stmt.order_by(col.desc().nulls_last(), Transaction.id.desc())
return stmt.order_by(col.asc().nulls_last(), Transaction.id.asc())
@router.get("", response_model=list[TransactionOut])
def list_transactions(
account_id: int | None = None,
@@ -83,6 +115,8 @@ def list_transactions(
category_id: int | None = None,
q: str | None = None,
status: str = "confirmed",
sort: str = "datum",
dir: str = "asc",
limit: int = 200,
offset: int = 0,
session: Session = Depends(get_session),
@@ -91,7 +125,7 @@ def list_transactions(
select(Transaction), account_id=account_id, date_from=date_from,
date_to=date_to, category_id=category_id, q=q, status=status,
)
stmt = stmt.order_by(Transaction.booking_date, Transaction.id).offset(offset).limit(limit)
stmt = _apply_sort(stmt, sort, dir).offset(offset).limit(limit)
return [TransactionOut.model_validate(t) for t in session.execute(stmt).scalars()]
+18 -12
View File
@@ -39,11 +39,25 @@
<button type="submit">Filtern</button>
</form>
{% set filter_params = {
'account_id': filters.account_id,
'date_from': filters.date_from.isoformat() if filters.date_from else None,
'date_to': filters.date_to.isoformat() if filters.date_to else None,
'category_id': filters.category_id,
'q': filters.q,
'status': filters.status,
} %}
{% macro sort_th(key, label, params, cur_sort, cur_dir) -%}
{%- set active = cur_sort == key -%}
<th>{{ label }} <a class="sort-btn{{ ' active' if active else '' }}" aria-label="Nach {{ label }} sortieren"
href="/buchungen?{% for k, v in params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ key }}&dir={{ 'desc' if active and cur_dir == 'asc' else 'asc' }}">{{ ('▲' if cur_dir == 'asc' else '▼') if active else '⇅' }}</a></th>
{%- endmacro %}
<table data-sort-mode="server">
<thead>
<tr>
<th>Datum</th><th>Konto</th><th>Verwendungszweck</th><th>Empfänger/Zahler</th>
<th>Betrag (€)</th><th>Kategorie</th><th></th>
{{ sort_th('datum', 'Datum', filter_params, sort, dir) }}{{ sort_th('konto', 'Konto', filter_params, sort, dir) }}
{{ sort_th('zweck', 'Verwendungszweck', filter_params, sort, dir) }}{{ sort_th('empfaenger', 'Empfänger/Zahler', filter_params, sort, dir) }}
{{ sort_th('betrag', 'Betrag (€)', filter_params, sort, dir) }}{{ sort_th('kategorie', 'Kategorie', filter_params, sort, dir) }}<th></th>
</tr>
</thead>
<tbody>
@@ -88,20 +102,12 @@
</table>
<p class="pager">
{% set pager_params = {
'account_id': filters.account_id,
'date_from': filters.date_from.isoformat() if filters.date_from else None,
'date_to': filters.date_to.isoformat() if filters.date_to else None,
'category_id': filters.category_id,
'q': filters.q,
'status': filters.status,
} %}
{% if page > 1 %}
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page - 1 }}">Zurück</a>
<a href="/buchungen?{% for k, v in filter_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ sort }}&dir={{ dir }}&page={{ page - 1 }}">Zurück</a>
{% endif %}
Seite {{ page }} von {{ total_pages }}
{% if page < total_pages %}
<a href="/buchungen?{% for k, v in pager_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}page={{ page + 1 }}">Weiter</a>
<a href="/buchungen?{% for k, v in filter_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ sort }}&dir={{ dir }}&page={{ page + 1 }}">Weiter</a>
{% endif %}
</p>