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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user