diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index 0bc5a4a..797ae99 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -2,13 +2,13 @@ from datetime import date -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, get_owned 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.csv_import import normalize_text 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()) +@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: """Überträge lösen, bevor das Konto verschwindet. diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b59ea2b..70a0efb 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -159,6 +159,12 @@ class AccountCreate(BaseModel): return _check_currency(v) +class AccountOrder(BaseModel): + """Konto-IDs in der gewünschten Reihenfolge.""" + + ids: list[int] + + class AccountUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) type: AccountType | None = None diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index 4b6ec8f..3ba5e4a 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -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.delete(f"/api/accounts/{quelle['id']}").status_code == 204 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 diff --git a/web/src/api.js b/web/src/api.js index b024ec4..753cd93 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -162,6 +162,7 @@ export const api = { createAccount: (body) => request("/accounts", { method: "POST", body }), updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }), deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }), + setAccountOrder: (ids) => request("/accounts/order", { method: "PUT", body: { ids } }), // Kategorien (Gruppen + Kategorien) listCategories: () => request("/categories"), diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index 571b28f..eda014d 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -72,6 +72,7 @@ const PATHS = { ), "chevron-right": , "chevron-down": , + "chevron-up": , tags: ( <> diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 6a3d831..fcde65f 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -167,7 +167,9 @@ "empty": "Noch keine Konten. Lege dein erstes Konto an.", "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.", - "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": { "girokonto": "Girokonto", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 6fb0b99..0a373b1 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -167,7 +167,9 @@ "empty": "No accounts yet. Create your first account.", "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.", - "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": { "girokonto": "Checking", diff --git a/web/src/pages/Accounts.jsx b/web/src/pages/Accounts.jsx index 3cd8c0c..f910de0 100644 --- a/web/src/pages/Accounts.jsx +++ b/web/src/pages/Accounts.jsx @@ -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) { if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; try { @@ -223,7 +239,7 @@ export default function Accounts() { {formatMoney(total, baseCurrency)}
- {accounts.map((a) => ( + {accounts.map((a, i) => (
@@ -236,6 +252,14 @@ export default function Accounts() {
+ +