feat: Kontenreihenfolge selbst bestimmen

Die Liste stand in der Anlegereihenfolge, die selten die gewuenschte ist.
Neuer Endpunkt PUT /accounts/order nimmt die IDs in der gewuenschten Folge
und schreibt die Position; er muss vor /{account_id} stehen, sonst faengt
die ID-Route den Pfad ab. Fremde IDs werden abgewiesen.

In der Kontenliste zwei Pfeile je Zeile, optimistisch sortiert, damit die
Liste beim Klicken nicht springt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 20:36:59 +02:00
co-authored by Claude Opus 5
parent ff897c196b
commit 9642901e4e
8 changed files with 79 additions and 5 deletions
+19 -2
View File
@@ -2,13 +2,13 @@
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import Account, AccountIdentifier, IdentifierKind, Transaction, TxnType, User from ..models import Account, AccountIdentifier, IdentifierKind, Transaction, TxnType, User
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate from ..schemas import AccountCreate, AccountIdentifierOut, AccountOrder, AccountOut, AccountUpdate
from ..services import balances, currency from ..services import balances, currency
from ..services.csv_import import normalize_text from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting from .settings import BASE_CURRENCY_KEY, get_user_setting
@@ -146,6 +146,23 @@ def update_account(
return to_out(db, acc, base_currency(db, user), date.today()) return to_out(db, acc, base_currency(db, user), date.today())
@router.put("/order", status_code=status.HTTP_204_NO_CONTENT)
def set_account_order(
payload: AccountOrder, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
"""Reihenfolge der Konten setzen (Liste der IDs in der gewünschten Folge).
Muss vor ``/{account_id}`` stehen, sonst fängt die ID-Route den Pfad ab.
"""
konten = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
fremd = [i for i in payload.ids if i not in konten]
if fremd:
raise HTTPException(status_code=404, detail=f"Unbekanntes Konto: {fremd[0]}")
for platz, konto_id in enumerate(payload.ids):
konten[konto_id].position = platz
db.commit()
def _entkoppele_uebertraege(db: Session, acc: Account) -> None: def _entkoppele_uebertraege(db: Session, acc: Account) -> None:
"""Überträge lösen, bevor das Konto verschwindet. """Überträge lösen, bevor das Konto verschwindet.
+6
View File
@@ -159,6 +159,12 @@ class AccountCreate(BaseModel):
return _check_currency(v) return _check_currency(v)
class AccountOrder(BaseModel):
"""Konto-IDs in der gewünschten Reihenfolge."""
ids: list[int]
class AccountUpdate(BaseModel): class AccountUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
type: AccountType | None = None type: AccountType | None = None
+21
View File
@@ -200,3 +200,24 @@ def test_konto_loeschen_erhaelt_zugang_beim_zielkonto(admin):
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000 assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204 assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000 assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
def test_kontenreihenfolge_laesst_sich_setzen(admin):
"""Die Liste stand in der Anlegereihenfolge; die will man selbst bestimmen."""
a = _mk_account(admin, name="Zuerst angelegt")
b = _mk_account(admin, name="Danach angelegt")
c = _mk_account(admin, name="Zuletzt angelegt")
namen = lambda: [x["name"] for x in admin.get("/api/accounts").json()
if x["name"].endswith("angelegt")]
assert namen() == ["Zuerst angelegt", "Danach angelegt", "Zuletzt angelegt"]
r = admin.put("/api/accounts/order", json={"ids": [c["id"], a["id"], b["id"]]})
assert r.status_code == 204, r.text
assert namen() == ["Zuletzt angelegt", "Zuerst angelegt", "Danach angelegt"]
def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client):
"""Fremde Konto-IDs in der Liste dürfen nichts verschieben."""
eigen = _mk_account(admin, name="Eigenes Konto")
r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]})
assert r.status_code == 404
+1
View File
@@ -162,6 +162,7 @@ export const api = {
createAccount: (body) => request("/accounts", { method: "POST", body }), createAccount: (body) => request("/accounts", { method: "POST", body }),
updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }), updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }),
deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }), deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }),
setAccountOrder: (ids) => request("/accounts/order", { method: "PUT", body: { ids } }),
// Kategorien (Gruppen + Kategorien) // Kategorien (Gruppen + Kategorien)
listCategories: () => request("/categories"), listCategories: () => request("/categories"),
+1
View File
@@ -72,6 +72,7 @@ const PATHS = {
), ),
"chevron-right": <path d="m9 18 6-6-6-6" />, "chevron-right": <path d="m9 18 6-6-6-6" />,
"chevron-down": <path d="m6 9 6 6 6-6" />, "chevron-down": <path d="m6 9 6 6 6-6" />,
"chevron-up": <path d="m18 15-6-6-6 6" />,
tags: ( tags: (
<> <>
<path d="M9 5H2v7l6.29 6.29c.94.94 2.48.94 3.42 0l3.58-3.58c.94-.94.94-2.48 0-3.42L9 5Z" /> <path d="M9 5H2v7l6.29 6.29c.94.94 2.48.94 3.42 0l3.58-3.58c.94-.94.94-2.48 0-3.42L9 5Z" />
+3 -1
View File
@@ -167,7 +167,9 @@
"empty": "Noch keine Konten. Lege dein erstes Konto an.", "empty": "Noch keine Konten. Lege dein erstes Konto an.",
"deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?", "deleteConfirm": "Dieses Konto und alle zugehörigen Buchungen löschen?",
"openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles.", "openingDateHint": "Stand am Ende dieses Tages. Buchungen bis dahin zählen nicht zum Kontostand, bleiben aber in Liste und Auswertungen. Ohne Stichtag zählt alles.",
"shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“." "shortIdentWarning": "Kurze Erkennungstexte treffen leicht Fremdes: „Euro“ steckt auch in „Europa-Park“ oder „PayPal Europe“. Solche Buchungen werden dann fälschlich zu Überträgen. Nimm lieber etwas Eindeutiges wie die IBAN oder „Revolut**5064*“.",
"moveUp": "Nach oben",
"moveDown": "Nach unten"
}, },
"accountTypes": { "accountTypes": {
"girokonto": "Girokonto", "girokonto": "Girokonto",
+3 -1
View File
@@ -167,7 +167,9 @@
"empty": "No accounts yet. Create your first account.", "empty": "No accounts yet. Create your first account.",
"deleteConfirm": "Delete this account and all its transactions?", "deleteConfirm": "Delete this account and all its transactions?",
"openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts.", "openingDateHint": "Balance at the end of that day. Bookings up to then don't count toward the balance but stay in the list and reports. Without a date, everything counts.",
"shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN." "shortIdentWarning": "Short match texts hit unrelated bookings: “Euro” also occurs in “Europa-Park” or “PayPal Europe”, turning those into transfers by mistake. Prefer something unique such as the IBAN.",
"moveUp": "Move up",
"moveDown": "Move down"
}, },
"accountTypes": { "accountTypes": {
"girokonto": "Checking", "girokonto": "Checking",
+25 -1
View File
@@ -193,6 +193,22 @@ export default function Accounts() {
} }
} }
// Reihenfolge per Pfeil: die Liste stand sonst in der Anlegereihenfolge.
// Optimistisch umsortieren, damit die Liste nicht bei jedem Klick springt.
async function verschiebe(index, richtung) {
const ziel = index + richtung;
if (ziel < 0 || ziel >= accounts.length) return;
const neu = [...accounts];
[neu[index], neu[ziel]] = [neu[ziel], neu[index]];
setAccounts(neu);
try {
await api.setAccountOrder(neu.map((a) => a.id));
} catch (e) {
toast(e.message, "error");
laden();
}
}
async function loeschen(acc) { async function loeschen(acc) {
if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
try { try {
@@ -223,7 +239,7 @@ export default function Accounts() {
<strong>{formatMoney(total, baseCurrency)}</strong> <strong>{formatMoney(total, baseCurrency)}</strong>
</div> </div>
<div className="account-list"> <div className="account-list">
{accounts.map((a) => ( {accounts.map((a, i) => (
<div className="account-row" key={a.id}> <div className="account-row" key={a.id}>
<IconChip name={a.icon} color={a.color} /> <IconChip name={a.icon} color={a.color} />
<div className="named-text grow"> <div className="named-text grow">
@@ -236,6 +252,14 @@ export default function Accounts() {
<Money cents={a.balance_cents} currency={a.currency} <Money cents={a.balance_cents} currency={a.currency}
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} /> baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
<div className="row-actions"> <div className="row-actions">
<button className="btn sm ghost" title={t("accounts.moveUp")}
disabled={i === 0} onClick={() => verschiebe(i, -1)}>
<Icon name="chevron-up" size={15} />
</button>
<button className="btn sm ghost" title={t("accounts.moveDown")}
disabled={i === accounts.length - 1} onClick={() => verschiebe(i, 1)}>
<Icon name="chevron-down" size={15} />
</button>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}> <button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}>
<Icon name="edit" size={15} /> <Icon name="edit" size={15} />
</button> </button>