Compare commits

...
4 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 50fc0e3353 feat: Buchungen lassen sich mit eigenen, farbigen Tags versehen und danach filtern
Die Kategorie beantwortet "wofuer war das Geld" und gilt genau einmal. Alles,
was quer dazu liegt – "Urlaub Island", "absetzbar", "zurueckfordern" –, hatte
bisher keinen Platz, weil eine Buchung davon mehrere auf einmal sein kann.

Tags werden auf einer eigenen Seite angelegt, jeder mit seiner Farbe. Gesetzt
werden sie an der Buchung: direkt in der Liste oder beim Bearbeiten. Die
Spalte laesst sich filtern, auch nach mehreren Tags gleichzeitig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:16:17 +02:00
ScarriffleandClaude Opus 5 a9ea0432d8 fix: Eine Sicherung mit Transfer-Schlagwort laesst sich wieder einspielen
Das feste Zielkonto eines Transfer-Schlagworts behielt beim Wiederherstellen
die alte Konto-Nummer. Die Nummern werden dabei aber neu vergeben, also lief
es ins Leere – und zwar so, dass die GANZE Wiederherstellung mit einem
Fremdschluessel-Fehler abbrach, nicht nur diese eine Zeile. Wer je ein
Zielkonto gewaehlt hatte, kam an seine Sicherung nicht mehr heran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 07:16:03 +02:00
ScarriffleandClaude Opus 5 e8c18adafb fix: Das Notizfeld sieht aus wie die anderen Felder, statt als winziges Kaestchen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 06:54:18 +02:00
ScarriffleandClaude Opus 5 14254b483e fix: Die Meldung sagt jetzt, wenn die Gegenseite schon anderweitig verbunden ist
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-19 06:53:47 +02:00
21 changed files with 1098 additions and 15 deletions
+2
View File
@@ -45,6 +45,7 @@ from .routers import (
rates,
settings as settings_router,
subscriptions,
tags,
transactions,
users,
)
@@ -214,6 +215,7 @@ api.include_router(settings_router.router)
api.include_router(policy.router)
api.include_router(accounts.router)
api.include_router(categories.router)
api.include_router(tags.router)
api.include_router(transactions.router)
api.include_router(budgets.router)
api.include_router(dashboard.router)
+58
View File
@@ -1281,3 +1281,61 @@ class BankAccountLink(Base):
except ValueError:
return {}
return daten if isinstance(daten, dict) else {}
class Tag(Base):
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
keine Pflicht, genau eines zu waehlen.
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
"""
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(60), nullable=False)
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
)
class TransactionTag(Base):
"""Welcher Tag haengt an welcher Buchung.
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
Buchung.
"""
__tablename__ = "transaction_tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
owner_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
transaction_id: Mapped[int] = mapped_column(
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
__table_args__ = (
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
)
+83
View File
@@ -0,0 +1,83 @@
"""Tags: frei vergebene Etiketten fuer Buchungen. Alles owner-gescoped.
Neben der Kategorie, nicht statt ihr. Die Kategorie sagt, wofuer das Geld war;
ein Tag alles, was quer dazu liegt – und davon duerfen mehrere an einer Buchung
haengen.
Die Zuordnung selbst wird hier NICHT gesetzt, sondern an der Buchung
(``PATCH /transactions/{id}`` mit ``tag_ids``). Sonst gaebe es zwei Wege, eine
Buchung zu aendern, und zwei Stellen, an denen man es vergessen kann.
"""
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 Tag, User
from ..schemas import TagCreate, TagOut, TagUpdate
router = APIRouter(prefix="/tags", tags=["tags"])
def _name_frei(db: Session, user: User, name: str, ausser_id: int | None = None) -> None:
"""Denselben Namen zweimal gaebe es in der Liste nicht zu unterscheiden."""
q = db.query(Tag.id).filter(Tag.owner_id == user.id, Tag.name == name)
if ausser_id is not None:
q = q.filter(Tag.id != ausser_id)
if q.first() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
detail=f"Den Tag „{name}“ gibt es schon.")
@router.get("", response_model=list[TagOut])
def list_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
# Alphabetisch: In einer Auswahlliste sucht man nach dem Namen, nicht
# danach, wann der Tag entstanden ist.
return (
db.query(Tag).filter(Tag.owner_id == user.id).order_by(Tag.name).all()
)
@router.post("", response_model=TagOut, status_code=status.HTTP_201_CREATED)
def create_tag(payload: TagCreate, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Leerer Name")
_name_frei(db, user, name)
tag = Tag(owner_id=user.id, name=name, color=payload.color)
db.add(tag)
db.commit()
db.refresh(tag)
return TagOut.model_validate(tag)
@router.patch("/{tag_id}", response_model=TagOut)
def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
tag = get_owned(db, Tag, tag_id, user)
daten = payload.model_dump(exclude_unset=True)
if "name" in daten and daten["name"] is not None:
name = daten["name"].strip()
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Leerer Name")
_name_frei(db, user, name, ausser_id=tag.id)
tag.name = name
if daten.get("color") is not None:
tag.color = daten["color"]
db.commit()
db.refresh(tag)
return TagOut.model_validate(tag)
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_tag(tag_id: int, db: Session = Depends(get_db),
user: User = Depends(get_current_user)):
"""Den Tag loeschen. Die Buchungen bleiben – es faellt nur das Etikett ab
(ON DELETE CASCADE auf ``transaction_tags``)."""
tag = get_owned(db, Tag, tag_id, user)
db.delete(tag)
db.commit()
+62 -6
View File
@@ -19,7 +19,9 @@ from ..models import (
Category,
InboxItem,
InboxStatus,
Tag,
Transaction,
TransactionTag,
TransferLink,
TxnType,
User,
@@ -82,7 +84,8 @@ def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> s
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
herkunft: dict[int, str] | None = None, start_tag: int = 1) -> TransactionOut:
herkunft: dict[int, str] | None = None, start_tag: int = 1,
tags: dict[int, list[int]] | None = None) -> TransactionOut:
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
if base == "EUR":
amount_base = txn.amount_eur_cents
@@ -113,9 +116,50 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
payee=txn.payee,
description=txn.description,
notes=txn.notes,
tag_ids=(tags or {}).get(txn.id, []),
)
def _tags(db: Session, user: User, txns: list[Transaction]) -> dict[int, list[int]]:
"""Die Tags je Buchung – als EINE Abfrage, nicht je Zeile.
Die Buchungsliste geht bis 5000 Zeilen; je Zeile zu fragen waere genau das
N+1, das die vier Nachbarn hier schon vermeiden.
"""
ids = [t.id for t in txns]
if not ids:
return {}
nach: dict[int, list[int]] = {}
for txn_id, tag_id in db.query(
TransactionTag.transaction_id, TransactionTag.tag_id
).filter(TransactionTag.owner_id == user.id,
TransactionTag.transaction_id.in_(ids)):
nach.setdefault(txn_id, []).append(tag_id)
return nach
def _setze_tags(db: Session, user: User, txn: Transaction, tag_ids: list[int]) -> None:
"""Die Tags einer Buchung auf genau diese Liste bringen.
Ersetzen statt ergaenzen: Die Oberflaeche schickt immer den vollen Stand,
sonst liesse sich ein Tag nie wieder abnehmen. Jede ID wird geprueft – ein
fremder Tag darf auch dann nicht haengenbleiben, wenn jemand die ID raet.
"""
gewuenscht = {int(i) for i in tag_ids}
for tag_id in gewuenscht:
get_owned(db, Tag, tag_id, user)
vorhanden = {
z.tag_id: z for z in db.query(TransactionTag).filter(
TransactionTag.owner_id == user.id,
TransactionTag.transaction_id == txn.id)
}
for tag_id, zeile in vorhanden.items():
if tag_id not in gewuenscht:
db.delete(zeile)
for tag_id in gewuenscht - set(vorhanden):
db.add(TransactionTag(owner_id=user.id, transaction_id=txn.id, tag_id=tag_id))
def _currency_map(db: Session, user: User) -> dict[int, str]:
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
return {row[0]: row[1] for row in rows}
@@ -184,7 +228,8 @@ def list_transactions(
ccy = _currency_map(db, user)
woher = _herkunft(db, user, rows)
tag = budget.start_tag(db, user)
return [to_out(db, t, base, ccy, woher, tag) for t in rows]
etiketten = _tags(db, user, rows)
return [to_out(db, t, base, ccy, woher, tag, etiketten) for t in rows]
@router.get("/dubletten")
@@ -332,10 +377,14 @@ def create_transaction(
)
buchung.setze_betraege(db, txn, from_acc, to_acc)
db.add(txn)
if payload.tag_ids:
db.flush() # erst mit id laesst sich das Etikett haengen
_setze_tags(db, user, txn, payload.tag_ids)
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.get("/{txn_id}", response_model=TransactionOut)
@@ -344,7 +393,8 @@ def get_transaction(
):
txn = get_owned(db, Transaction, txn_id, user)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.patch("/{txn_id}", response_model=TransactionOut)
@@ -390,11 +440,16 @@ def update_transaction(
else:
txn.to_account_id = None
buchung.setze_betraege(db, txn, from_acc, to_acc)
# Nach exclude_unset: mitgeschickt heisst ersetzen (auch mit leerer Liste),
# weggelassen heisst unberuehrt lassen.
if data.get("tag_ids") is not None:
_setze_tags(db, user, txn, data["tag_ids"])
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
class TransactionTransferIn(BaseModel):
@@ -426,7 +481,8 @@ def make_transfer(txn_id: int, payload: TransactionTransferIn,
db.commit()
db.refresh(txn)
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
_herkunft(db, user, [txn]), budget.start_tag(db, user))
_herkunft(db, user, [txn]), budget.start_tag(db, user),
_tags(db, user, [txn]))
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
+34
View File
@@ -346,6 +346,28 @@ class SubscriptionListOut(BaseModel):
summary: SubscriptionSummary
# --- Tags ------------------------------------------------------------------
class TagOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str
class TagCreate(BaseModel):
name: str = Field(min_length=1, max_length=60)
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
color: str = "#6b7480"
class TagUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=60)
color: str | None = None
# --- Kategorien ------------------------------------------------------------
@@ -433,6 +455,9 @@ class TransactionCreate(BaseModel):
payee: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=255)
notes: str | None = None
# Beim Anlegen gleich mitgeben, sonst muesste man die Buchung direkt
# danach noch einmal oeffnen, nur um sie zu etikettieren.
tag_ids: list[int] = []
class TransactionUpdate(BaseModel):
@@ -452,6 +477,10 @@ class TransactionUpdate(BaseModel):
payee: str | None = Field(default=None, max_length=255)
description: str | None = Field(default=None, max_length=255)
notes: str | None = None
# Die Tags dieser Buchung, vollstaendig. Mitgeschickt heisst ERSETZEN –
# eine leere Liste nimmt alle weg. Weggelassen laesst sie unberuehrt;
# darum haengt hier ``exclude_unset`` im Router dran.
tag_ids: list[int] | None = None
class TransactionOut(BaseModel):
@@ -486,6 +515,11 @@ class TransactionOut(BaseModel):
payee: str | None
description: str | None
notes: str | None
# Die Tags dieser Buchung. Anders als die Uebertrags-Verknuepfung stehen
# sie IM Schema und nicht in einem eigenen Endpunkt: Jede Zeile der
# Buchungsliste zeigt und filtert nach ihnen, ein zweiter Abruf wuerde im
# Browser doch nur wieder zusammengefuegt.
tag_ids: list[int] = []
class RateStatus(BaseModel):
+19 -1
View File
@@ -55,7 +55,9 @@ from ..models import (
LinkIgnored,
PlannedIncomeLine,
RecoveryCode,
Tag,
Transaction,
TransactionTag,
TransferLink,
User,
UserSetting,
@@ -135,8 +137,14 @@ USER_TABLES: list[TableSpec] = [
{"budget_period_id": "budget_periods", "category_id": "categories"},
label="Budget-Monatswerte"),
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
# target_account_id gehört dazu: Ein Transfer-Schlagwort mit festem
# Zielkonto trug sonst die ALTE Konto-Nummer in die neue Datenbank, und die
# Wiederherstellung brach mit einem Fremdschlüssel-Fehler ab – nicht nur
# diese Zeile, die ganze Sicherung liess sich nicht mehr einspielen.
# accounts steht weiter oben, die Übersetzung greift also.
TableSpec("categorization_rules", CategorizationRule, "owner",
{"category_id": "categories"}, label="Schlagwortregeln"),
{"category_id": "categories", "target_account_id": "accounts"},
label="Schlagwortregeln"),
TableSpec("user_settings", UserSetting, "user", has_id=False,
label="Persönliche Einstellungen"),
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
@@ -154,6 +162,12 @@ USER_TABLES: list[TableSpec] = [
TableSpec("link_ignored", LinkIgnored, "owner",
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
label="Abgelehnte Übertrags-Vorschläge"),
# Erst die Tags, dann die Zuordnung – ein refs-Verweis darf nur auf eine
# schon eingefügte Tabelle zeigen.
TableSpec("tags", Tag, "owner", label="Tags"),
TableSpec("transaction_tags", TransactionTag, "owner",
{"transaction_id": "transactions", "tag_id": "tags"},
label="Tag-Zuordnungen"),
# Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
# der Hash – der Klartext existiert nirgends.
@@ -412,6 +426,10 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
# Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
"subscription_logos": lambda r: ("logo", r.get("subscription_id")),
# Ohne die beiden Einträge scheitert ein zweiter Ergänzen-Lauf an
# (owner_id, name) bzw. (transaction_id, tag_id).
"tags": lambda r: ("name", (r.get("name") or "").strip().lower()),
"transaction_tags": lambda r: ("zuordnung", r.get("transaction_id"), r.get("tag_id")),
"transactions": lambda r: (
("uid", r["source_uid"]) if r.get("source_uid")
else ("hash", r["import_hash"]) if r.get("import_hash")
+14
View File
@@ -173,6 +173,20 @@ def _pruefe_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
datum, exakt)
if schon is None:
return
# Haengt die gefundene Buchung schon an einer anderen, ist sie NICHT die
# Gegenseite dieser hier. Der Rat „verbinden" ginge dann ins Leere: Die
# Vorschlagsliste laesst vergebene Buchungen aus, es kaeme nie ein
# Vorschlag – und der Nutzer sucht etwas, das es nicht gibt.
if _schon_verknuepft(db, user, schon):
raise UebertragFehler(
f"Auf '{ziel.name}' steht zwar eine passende Buchung vom "
f"{schon.date.strftime('%d.%m.%Y')}, die ist aber schon mit einer "
f"anderen Buchung verbunden – also nicht die Gegenseite dieser "
f"hier. Verbinden geht deshalb nicht. Sieh auf '{ziel.name}' nach, "
f"ob die vorhandene Verknüpfung stimmt, oder ob der zweite Zugang "
f"dort schlicht fehlt.",
nachfragen=True,
)
raise UebertragFehler(
f"Auf '{ziel.name}' steht schon eine passende Buchung vom "
f"{schon.date.strftime('%d.%m.%Y')}. Beide Seiten sind also bereits "
@@ -0,0 +1,60 @@
"""Tags fuer Buchungen.
Die Kategorie beantwortet „wofuer war das Geld". Alles, was quer dazu liegt,
hatte bisher keinen Platz: „Urlaub Island", „steuerlich absetzbar",
„zurueckfordern". Dafuer gibt es jetzt frei vergebene Etiketten mit eigener
Farbe, mehrere je Buchung.
Zwei neue Tabellen, keine bestehende wird angefasst – ``create_table`` ist
deshalb unbedenklich. Die Warnung gegen ``batch_alter_table`` aus 0034-0037
betrifft den UMBAU vorhandener Tabellen, der ueber ON DELETE CASCADE haengende
Zeilen mitrisse (tests/test_migrations.py).
Revision ID: 0038
Revises: 0037
"""
import sqlalchemy as sa
from alembic import op
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(length=60), nullable=False),
sa.Column("color", sa.String(length=9), nullable=False,
server_default="#6b7480"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
)
op.create_index("ix_tags_owner_id", "tags", ["owner_id"])
op.create_table(
"transaction_tags",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("owner_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("transaction_id", sa.Integer(),
sa.ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False),
sa.Column("tag_id", sa.Integer(),
sa.ForeignKey("tags.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
)
op.create_index("ix_transaction_tags_owner_id", "transaction_tags", ["owner_id"])
op.create_index("ix_transaction_tags_transaction_id", "transaction_tags",
["transaction_id"])
op.create_index("ix_transaction_tags_tag_id", "transaction_tags", ["tag_id"])
def downgrade() -> None:
op.drop_table("transaction_tags")
op.drop_table("tags")
+41
View File
@@ -87,3 +87,44 @@ def test_jede_nutzertabelle_ist_im_backup_beruecksichtigt():
f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: "
f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen."
)
def test_transfer_schlagwort_behaelt_sein_zielkonto(admin):
"""Ein Transfer-Schlagwort mit festem Zielkonto trug beim Wiedereinspielen
die ALTE Konto-Nummer in die neue Datenbank. Beim Einspielen brach dann die
ganze Sicherung mit einem Fremdschluessel-Fehler ab - nicht nur diese eine
Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung also gar
nicht mehr heran."""
import secrets
def _konto(name):
return admin.post("/api/accounts", json={
"name": name, "type": "sparkonto", "currency": "CHF",
"opening_balance_cents": 0}).json()
# Erst Luecken in die Konto-Nummern reissen, dann das Zielkonto anlegen.
# Ohne Luecken vergibt SQLite beim Einspielen zufaellig dieselben Nummern
# und der Fehler bliebe unsichtbar - genau deshalb ist er so lange
# unbemerkt geblieben.
weg = [_konto(f"Weg {secrets.token_hex(3)}") for _ in range(5)]
ziel = _konto(f"Sparen {secrets.token_hex(3)}")
for k in weg:
assert admin.delete(f"/api/accounts/{k['id']}").status_code in (200, 204)
wort = f"um chf sparen {secrets.token_hex(5)}"
r = admin.post("/api/categories/transfer-keywords",
json={"word": wort, "target_account_id": ziel["id"]})
assert r.status_code == 201, r.text
daten = json.loads(admin.get("/api/export/backup.json").content)
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
"application/json")},
data={"mode": "replace"})
assert r.status_code == 200, r.text
konten = {a["name"]: a["id"] for a in admin.get("/api/accounts").json()}
regel = next(w for w in admin.get("/api/categories/transfer-keywords").json()
if w["word"] == wort)
assert regel["target_account_id"] == konten[ziel["name"]], \
"das Zielkonto muss auf die NEUE Nummer zeigen"
+58
View File
@@ -455,3 +455,61 @@ def test_verknuepfte_buchungen_landen_nicht_in_uebersprungen(admin):
# Und sie bleibt unangetastet: kein Uebertrag, kein verschobenes Geld.
assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense"
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == betrag
def _tx(admin, konto, typ, datum, cents, payee):
return admin.post("/api/transactions", json={
"type": typ, "date": datum, "account_id": konto["id"],
"amount_cents": cents, "payee": payee}).json()
def test_vergebene_gegenseite_wird_als_solche_benannt(admin):
"""Die Pruefung fragt „gibt es auf dem Zielkonto eine passende Buchung?",
nicht „ist sie noch frei?". Haengt die gefundene schon an einer anderen
Buchung, ist sie NICHT die Gegenseite dieser hier – und der Rat „verbinden"
geht ins Leere, denn die Vorschlagsliste laesst vergebene Buchungen aus.
Dann muss die Meldung sagen, was wirklich los ist."""
quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}")
pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}")
anderes = _kto(admin, name=f"Anderes {secrets.token_hex(3)}")
kennung = f"um chf sparen {secrets.token_hex(6)}"
betrag = 62 + secrets.randbelow(900)
# Der Zugang im Pocket gehoert schon zu einer Buchung vom dritten Konto.
zugang = _tx(admin, pocket, "income", "2026-05-20", betrag, "Pocket")
fremd = _tx(admin, anderes, "expense", "2026-05-20", betrag, "Fremd")
assert admin.post("/api/uebertraege/verbinden",
json={"a_id": fremd["id"], "b_id": zugang["id"]}).status_code == 200
# Und jetzt kommt die Revolut-Ausgabe, gleicher Tag, gleicher Betrag.
raus = _tx(admin, quelle, "expense", "2026-05-20", betrag, kennung.upper())
assert admin.post("/api/categories/transfer-keywords", json={
"word": kennung, "target_account_id": pocket["id"]}).status_code == 201
r = admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}).json()
grund = next(z["grund"] for z in r["uebersprungen"] if z["text"] == kennung.upper())
assert "schon mit einer anderen Buchung verbunden" in grund
assert "Richtig ist hier, die beiden zu verbinden" not in grund, \
"zum Verbinden raten waere hier falsch – die Gegenseite ist vergeben"
# Geblockt wird weiter: kein Uebertrag, kein verschobenes Geld.
assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense"
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == betrag
def test_freie_gegenseite_raet_weiter_zum_verbinden(admin):
"""Die Gegenprobe: Ist die Gegenseite noch frei, bleibt es beim alten Rat.
Sonst schluckte der neue Zweig alle Faelle."""
quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}")
pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}")
kennung = f"um chf sparen {secrets.token_hex(6)}"
betrag = 62 + secrets.randbelow(900)
_tx(admin, pocket, "income", "2026-05-20", betrag, "Pocket")
_tx(admin, quelle, "expense", "2026-05-20", betrag, kennung.upper())
assert admin.post("/api/categories/transfer-keywords", json={
"word": kennung, "target_account_id": pocket["id"]}).status_code == 201
r = admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}).json()
grund = next(z["grund"] for z in r["uebersprungen"] if z["text"] == kennung.upper())
assert "Richtig ist hier, die beiden zu verbinden" in grund
+182
View File
@@ -0,0 +1,182 @@
"""Tags: frei vergebene Etiketten fuer Buchungen, mit eigener Farbe.
Die Kategorie beantwortet "wofuer war das Geld". Alles, was quer dazu liegt –
"Urlaub Island", "steuerlich absetzbar", "zurueckfordern" –, passt in keine
Kategorie, weil eine Buchung davon mehrere auf einmal sein kann. Genau das
koennen Tags, und genau daran haengt hier alles: mehrere je Buchung, frei
benannt, und beim Abnehmen darf auch keiner uebrig bleiben.
Die Datenbank lebt ueber den ganzen Testlauf. Namen und Betraege deshalb
eindeutig wuerfeln, sonst stolpert man ueber die Daten anderer Tests.
"""
import json
import secrets
from conftest import login_ready, new_user
def _tag(admin, name=None, color="#3f51b5"):
r = admin.post("/api/tags", json={
"name": name or f"Tag {secrets.token_hex(4)}", "color": color})
assert r.status_code == 201, r.text
return r.json()
def _konto(admin, name=None):
return admin.post("/api/accounts", json={
"name": name or f"Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "CHF", "opening_balance_cents": 0}).json()
def _buchung(admin, konto, cents=None, payee="Testkauf"):
return admin.post("/api/transactions", json={
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
"amount_cents": cents or (100 + secrets.randbelow(9000)),
"payee": payee}).json()
def test_tag_anlegen_umbenennen_umfaerben_loeschen(admin):
name = f"Urlaub {secrets.token_hex(4)}"
tag = _tag(admin, name, "#2a78d6")
assert tag["name"] == name and tag["color"] == "#2a78d6"
neu = f"Reise {secrets.token_hex(4)}"
r = admin.patch(f"/api/tags/{tag['id']}", json={"name": neu, "color": "#eb6834"})
assert r.status_code == 200, r.text
assert r.json()["name"] == neu and r.json()["color"] == "#eb6834"
assert admin.delete(f"/api/tags/{tag['id']}").status_code == 204
assert tag["id"] not in {x["id"] for x in admin.get("/api/tags").json()}
def test_derselbe_name_zweimal_wird_abgelehnt(admin):
"""Zwei gleich heissende Tags liessen sich in der Auswahl nicht unterscheiden."""
name = f"Doppelt {secrets.token_hex(4)}"
_tag(admin, name)
r = admin.post("/api/tags", json={"name": name, "color": "#000000"})
assert r.status_code == 409, r.text
# Auch beim Umbenennen auf einen vergebenen Namen.
anderer = _tag(admin)
assert admin.patch(f"/api/tags/{anderer['id']}", json={"name": name}).status_code == 409
# Auf den EIGENEN Namen umbenennen bleibt erlaubt.
assert admin.patch(f"/api/tags/{anderer['id']}",
json={"name": anderer["name"], "color": "#111111"}).status_code == 200
def test_leerer_name_wird_abgelehnt(admin):
assert admin.post("/api/tags", json={"name": " "}).status_code == 400
def test_eine_buchung_traegt_mehrere_tags(admin):
konto = _konto(admin)
a, b = _tag(admin), _tag(admin)
txn = _buchung(admin, konto)
assert txn["tag_ids"] == []
r = admin.patch(f"/api/transactions/{txn['id']}",
json={"tag_ids": [a["id"], b["id"]]})
assert r.status_code == 200, r.text
assert sorted(r.json()["tag_ids"]) == sorted([a["id"], b["id"]])
# Auch beim Einzelabruf und in der Liste.
assert sorted(admin.get(f"/api/transactions/{txn['id']}").json()["tag_ids"]) \
== sorted([a["id"], b["id"]])
aus_liste = next(t for t in admin.get("/api/transactions").json() if t["id"] == txn["id"])
assert sorted(aus_liste["tag_ids"]) == sorted([a["id"], b["id"]])
def test_mitgeschickt_heisst_ersetzen_weggelassen_heisst_unberuehrt(admin):
"""Die Oberflaeche schickt immer den vollen Stand. Eine leere Liste muss
deshalb alle abnehmen - sonst wuerde man einen Tag nie wieder los. Wer
``tag_ids`` gar nicht mitschickt (etwa beim Aendern des Betrags), darf die
Tags aber nicht verlieren."""
konto = _konto(admin)
a, b = _tag(admin), _tag(admin)
txn = _buchung(admin, konto)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [a["id"], b["id"]]})
# Ersetzen durch genau einen.
r = admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [b["id"]]})
assert r.json()["tag_ids"] == [b["id"]]
# Etwas anderes aendern - die Tags bleiben.
r = admin.patch(f"/api/transactions/{txn['id']}", json={"amount_cents": 4242})
assert r.json()["tag_ids"] == [b["id"]], "ohne tag_ids darf sich nichts aendern"
# Leere Liste nimmt alle ab.
r = admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": []})
assert r.json()["tag_ids"] == []
def test_tag_loeschen_laesst_die_buchung_stehen(admin):
"""Es verschwindet das Etikett, nie eine Buchung."""
konto = _konto(admin)
tag = _tag(admin)
txn = _buchung(admin, konto, cents=777)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
assert admin.delete(f"/api/tags/{tag['id']}").status_code == 204
danach = admin.get(f"/api/transactions/{txn['id']}").json()
assert danach["amount_cents"] == 777
assert danach["tag_ids"] == []
def test_buchung_loeschen_laesst_den_tag_stehen(admin):
konto = _konto(admin)
tag = _tag(admin)
txn = _buchung(admin, konto)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
assert admin.delete(f"/api/transactions/{txn['id']}").status_code == 204
assert tag["id"] in {x["id"] for x in admin.get("/api/tags").json()}
def test_fremder_tag_bleibt_unerreichbar(admin):
"""Mandanten-Trennung: 404, nicht 403 - wie ueberall im Projekt."""
fremd = _tag(admin)
anderer, _ = login_ready(*new_user(admin))
assert fremd["id"] not in {x["id"] for x in anderer.get("/api/tags").json()}
assert anderer.patch(f"/api/tags/{fremd['id']}", json={"name": "geklaut"}).status_code == 404
assert anderer.delete(f"/api/tags/{fremd['id']}").status_code == 404
# Und er laesst sich auch nicht an die eigene Buchung haengen.
konto = _konto(anderer)
txn = _buchung(anderer, konto)
assert anderer.patch(f"/api/transactions/{txn['id']}",
json={"tag_ids": [fremd["id"]]}).status_code == 404
assert anderer.get(f"/api/transactions/{txn['id']}").json()["tag_ids"] == []
def test_tags_ueberleben_das_backup(admin):
konto = _konto(admin)
name = f"Backup {secrets.token_hex(4)}"
tag = _tag(admin, name, "#1baf7a")
# Der Text muss eindeutig sein, nicht der Betrag: Nach dem Einspielen sind
# die IDs neu vergeben, und andere Tests legen laufend Buchungen an.
text = f"Tagtraeger {secrets.token_hex(4)}"
txn = _buchung(admin, konto, cents=5151, payee=text)
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
daten = json.loads(admin.get("/api/export/backup.json").content)
gesichert = [x for x in daten["data"]["tags"] if x["name"] == name]
assert gesichert and gesichert[0]["color"] == "#1baf7a"
assert daten["data"]["transaction_tags"], "die Zuordnung muss mitgesichert sein"
admin.delete(f"/api/tags/{tag['id']}")
r = admin.post("/api/backup/restore",
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
"application/json")},
data={"mode": "replace"})
assert r.status_code == 200, r.text
zurueck = [x for x in admin.get("/api/tags").json() if x["name"] == name]
assert zurueck and zurueck[0]["color"] == "#1baf7a"
wieder = [t for t in admin.get("/api/transactions").json() if t["payee"] == text]
assert wieder, "die Buchung muss nach dem Einspielen wieder da sein"
assert wieder[0]["tag_ids"] == [zurueck[0]["id"]], \
"und ihr Tag muss wieder an ihr haengen"
+3
View File
@@ -10,6 +10,7 @@ import ChangePassword from "./pages/ChangePassword";
import Dashboard from "./pages/Dashboard";
import Accounts from "./pages/Accounts";
import Categories from "./pages/Categories";
import Tags from "./pages/Tags";
import Transactions from "./pages/Transactions";
import Budget from "./pages/Budget";
import Abos from "./pages/Abos";
@@ -51,6 +52,7 @@ function Sidebar() {
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
<NavItem to="/tags" icon="tags" label={t("nav.tags")} />
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/eingang" icon="inbox" label={t("nav.todo")} />
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
@@ -142,6 +144,7 @@ export default function App() {
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
<Route path="/tags" element={<Protected><Tags /></Protected>} />
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
{/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen,
stand jede Bank-Buchung ohne Kategorie in beiden Listen. */}
+7
View File
@@ -338,6 +338,13 @@ export const api = {
updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }),
deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }),
// Tags. Gesetzt werden sie an der Buchung (updateTransaction mit tag_ids),
// hier geht es nur um die Tags selbst.
listTags: () => request("/tags"),
createTag: (body) => request("/tags", { method: "POST", body }),
updateTag: (id, body) => request(`/tags/${id}`, { method: "PATCH", body }),
deleteTag: (id) => request(`/tags/${id}`, { method: "DELETE" }),
// Wechselkurse
rateStatus: () => request("/rates/status"),
convertRate: (amountCents, from, to, on) => {
+19
View File
@@ -0,0 +1,19 @@
// Farbiges Text-Plättchen für einen Tag.
//
// Nicht `.badge`: das kennt nur feste Varianten (accent/danger/warn/ok), ein
// Tag trägt aber eine frei gewählte Farbe. Der getönte Hintergrund stammt aus
// demselben Kniff wie beim IconChip – `${color}22` ist dieselbe Farbe mit 13 %
// Deckkraft. Dadurch bleibt der Text in Hell UND Dunkel lesbar, ohne dass für
// jede Farbe ein zweiter Wert gepflegt werden müsste.
export default function TagChip({ name, color = "#6b7480", onRemove, removeTitle }) {
return (
<span className="tag-chip" style={{ color, background: `${color}22` }}>
{name}
{onRemove && (
<button type="button" title={removeTitle} onClick={onRemove} aria-label={removeTitle}>
×
</button>
)}
</span>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
import TagChip from "./TagChip";
// Mehrfachauswahl für Tags. Bewusst nach dem Vorbild von CategorySelect
// gebaut und mit denselben `.catsel-*`-Klassen: Das Panel wird per Portal mit
// fester Position gerendert, weil es sonst in der scrollbaren Buchungstabelle
// abgeschnitten würde. Unterschied zum Vorbild: flache Liste statt Baum, und
// ein Klick schaltet um statt zu schliessen – man vergibt selten genau einen.
export default function TagSelect({
tags = [],
value = [],
onChange,
minWidth = 0,
disabled = false,
placeholder = null,
}) {
const { t } = useTranslation();
const gewaehlt = useMemo(() => new Set((value || []).map(Number)), [value]);
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [pos, setPos] = useState(null);
const btnRef = useRef(null);
const panelRef = useRef(null);
const searchRef = useRef(null);
const byId = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
const sel = useMemo(
() => [...gewaehlt].map((id) => byId[id]).filter(Boolean),
[gewaehlt, byId],
);
function computePos() {
const el = btnRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const width = Math.max(r.width, 240);
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const desired = 320;
let top;
let maxHeight;
if (spaceBelow >= 220 || spaceBelow >= spaceAbove) {
maxHeight = Math.min(desired, spaceBelow - 10);
top = r.bottom + 4;
} else {
maxHeight = Math.min(desired, spaceAbove - 10);
top = r.top - 4 - maxHeight;
}
let left = r.left;
if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width;
if (left < 8) left = 8;
setPos({ left, top, width, maxHeight });
}
function openPanel() {
if (disabled) return;
computePos();
setQ("");
setOpen(true);
}
useEffect(() => {
if (!open) return undefined;
searchRef.current?.focus();
const onDown = (e) => {
if (panelRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
setOpen(false);
};
const onKey = (e) => e.key === "Escape" && setOpen(false);
// Wie beim CategorySelect: Scrollen INNERHALB der Liste darf sie nicht
// schliessen, Scrollen der Seite darunter schon.
const onScrollResize = (e) => {
if (e?.target && panelRef.current?.contains(e.target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScrollResize, true);
window.addEventListener("resize", onScrollResize);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScrollResize, true);
window.removeEventListener("resize", onScrollResize);
};
}, [open]);
function umschalten(id) {
const next = new Set(gewaehlt);
if (next.has(id)) next.delete(id);
else next.add(id);
// Offen lassen: Wer einen Tag setzt, setzt oft gleich den zweiten.
onChange([...next]);
}
const ql = q.trim().toLowerCase();
const sichtbar = ql ? tags.filter((x) => x.name.toLowerCase().includes(ql)) : tags;
return (
<div className="catsel" style={{ minWidth }}>
<button
type="button"
ref={btnRef}
className={`catsel-btn${open ? " open" : ""}`}
onClick={() => (open ? setOpen(false) : openPanel())}
disabled={disabled}
>
{sel.length ? (
<span className="catsel-value tag-chips">
{sel.map((x) => <TagChip key={x.id} name={x.name} color={x.color} />)}
</span>
) : (
<span className="catsel-value muted ellipsis">
{placeholder || t("tags.choose")}
</span>
)}
<Icon name="chevron-down" size={15} className="catsel-caret" />
</button>
{open && pos && createPortal(
<div
ref={panelRef}
className="catsel-panel"
style={{ left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight }}
>
<div className="catsel-search">
<Icon name="search" size={14} />
<input
ref={searchRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("tags.searchPlaceholder")}
/>
</div>
<div className="catsel-list">
{sichtbar.map((x) => (
<button
type="button"
key={x.id}
className={`catsel-opt${gewaehlt.has(x.id) ? " selected" : ""}`}
onClick={() => umschalten(x.id)}
>
<TagChip name={x.name} color={x.color} />
<span className="grow" />
{gewaehlt.has(x.id) && <Icon name="check" size={15} className="catsel-check" />}
</button>
))}
{sichtbar.length === 0 && (
<div className="catsel-empty muted small">
{tags.length === 0 ? t("tags.empty") : t("tags.noMatch")}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
+43
View File
@@ -2036,3 +2036,46 @@ tr.row-dup {
.sso-zeile:last-child { border-bottom: none; }
.sso-haken { color: var(--ok); flex: none; }
.btn.sso-verknuepfen { justify-content: center; text-decoration: none; }
/* ---------- Tags ---------- */
/* Farbiges Text-Plaettchen. Die Farbe kommt aus dem Datensatz und steht
deshalb inline am Element (siehe TagChip); hier nur die Form. */
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 9px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 560;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tag-chip button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
padding: 0;
border: none;
border-radius: 50%;
background: none;
color: inherit;
font-size: 0.95rem;
line-height: 1;
opacity: 0.65;
cursor: pointer;
}
.tag-chip button:hover { opacity: 1; }
/* Mehrere Plaettchen im Auswahlknopf: Sie duerfen umbrechen, sonst wuerde in
der Tabellenzelle ab dem zweiten Tag nichts mehr zu sehen sein. */
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
}
+18 -1
View File
@@ -24,7 +24,8 @@
"users": "Benutzer",
"settings": "Einstellungen",
"todo": "Zu erledigen",
"abos": "Abos"
"abos": "Abos",
"tags": "Tags"
},
"login": {
"subtitle": "Bitte anmelden",
@@ -242,6 +243,22 @@
"autoTarget": "automatisch",
"empty": "Noch keine Transfer-Schlagwörter."
},
"tags": {
"title": "Tags",
"intro": "Tags liegen quer zur Kategorie: Eine Buchung hat genau eine Kategorie, aber beliebig viele Tags – etwa „Urlaub Island“, „absetzbar“ oder „zurückfordern“. Gesetzt werden sie an der Buchung, in der Liste oder beim Bearbeiten.",
"new": "Neuer Tag",
"edit": "Tag bearbeiten",
"name": "Name",
"color": "Farbe",
"preview": "Vorschau",
"column": "Tags",
"none": "Keine Tags",
"choose": "Tags wählen …",
"searchPlaceholder": "Tag suchen …",
"noMatch": "Kein Tag passt dazu.",
"empty": "Noch keine Tags angelegt.",
"deleteConfirm": "Den Tag „{{name}}“ löschen? Die Buchungen bleiben stehen – sie verlieren nur dieses Etikett."
},
"categories": {
"title": "Kategorien",
"newGroup": "Neue Gruppe",
+18 -1
View File
@@ -24,7 +24,8 @@
"users": "Users",
"settings": "Settings",
"todo": "To do",
"abos": "Subscriptions"
"abos": "Subscriptions",
"tags": "Tags"
},
"login": {
"subtitle": "Please sign in",
@@ -242,6 +243,22 @@
"autoTarget": "automatic",
"empty": "No transfer keywords yet."
},
"tags": {
"title": "Tags",
"intro": "Tags sit across categories: a transaction has exactly one category but any number of tags – say “Iceland trip”, “deductible” or “claim back”. You set them on the transaction, in the list or while editing.",
"new": "New tag",
"edit": "Edit tag",
"name": "Name",
"color": "Colour",
"preview": "Preview",
"column": "Tags",
"none": "No tags",
"choose": "Choose tags …",
"searchPlaceholder": "Search tag …",
"noMatch": "No tag matches.",
"empty": "No tags yet.",
"deleteConfirm": "Delete the tag “{{name}}”? The transactions stay – they just lose this label."
},
"categories": {
"title": "Categories",
"newGroup": "New group",
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import Icon from "../components/Icon";
import Modal from "../components/Modal";
import TagChip from "../components/TagChip";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
// Tags pflegen: anlegen, umbenennen, umfärben, löschen.
//
// Bewusst eine eigene Seite und nicht ein Abschnitt unter den Kategorien: Ein
// Tag ist etwas anderes als eine Kategorie. Die Kategorie beantwortet „wofür
// war das Geld" und gilt genau einmal; ein Tag legt sich quer darüber und darf
// mehrfach an derselben Buchung hängen.
//
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
const STANDARDFARBE = "#3f51b5";
export default function Tags() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const [tags, setTags] = useState([]);
const [modal, setModal] = useState(null); // { editing, form }
async function laden() {
try {
setTags(await api.listTags());
} catch (err) {
toast(err.message, "error");
}
}
useEffect(() => { laden(); }, []);
function oeffnen(editing = null) {
setModal({
editing,
form: editing
? { name: editing.name, color: editing.color }
: { name: "", color: STANDARDFARBE },
});
}
async function speichern(e) {
e.preventDefault();
const { editing, form } = modal;
const rumpf = { name: form.name.trim(), color: form.color };
try {
if (editing) await api.updateTag(editing.id, rumpf);
else await api.createTag(rumpf);
setModal(null);
laden();
} catch (err) {
toast(err.message, "error");
}
}
async function loeschen(tag) {
if (!(await confirm({
title: t("common.delete"),
message: t("tags.deleteConfirm", { name: tag.name }),
danger: true,
confirmLabel: t("common.delete"),
}))) return;
try {
await api.deleteTag(tag.id);
laden();
} catch (err) {
toast(err.message, "error");
}
}
return (
<div>
<div className="page-head">
<h1>{t("tags.title")}</h1>
<button className="btn primary" onClick={() => oeffnen()}>
<Icon name="plus" size={16} /> {t("tags.new")}
</button>
</div>
<div className="card">
<p className="muted small" style={{ marginTop: 0 }}>{t("tags.intro")}</p>
{tags.map((tag) => (
<div className="cat-row" key={tag.id}>
<TagChip name={tag.name} color={tag.color} />
<span className="grow" />
<button className="btn sm ghost" title={t("common.edit")} onClick={() => oeffnen(tag)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tag)}>
<Icon name="trash" size={14} />
</button>
</div>
))}
{tags.length === 0 && <div className="empty-state">{t("tags.empty")}</div>}
</div>
{modal && (
<Modal
title={modal.editing ? t("tags.edit") : t("tags.new")}
onClose={() => setModal(null)}
footer={
<>
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
<button className="btn primary" form="tag-form" type="submit">{t("common.save")}</button>
</>
}
>
<form id="tag-form" onSubmit={speichern}>
<label>
<span>{t("tags.name")}</span>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus
maxLength={60}
value={modal.form.name}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })}
required
/>
</label>
<label>
<span>{t("tags.color")}</span>
<div className="color-row">
<input
type="color"
value={modal.form.color}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })}
/>
<TagChip name={modal.form.name.trim() || t("tags.preview")} color={modal.form.color} />
</div>
</label>
</form>
</Modal>
)}
</div>
);
}
+65 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
import TagSelect from "../components/TagSelect";
import Modal from "../components/Modal";
import BudgetZeitraum from "../components/BudgetZeitraum";
import AccountSelect from "../components/AccountSelect";
@@ -33,6 +34,7 @@ function emptyForm() {
payee: "",
description: "",
notes: "",
tag_ids: [],
budget_as_expense: true,
budget_category_id: "",
// Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es.
@@ -56,12 +58,17 @@ export default function Transactions() {
const [uebertrag, setUebertrag] = useState(null);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [tags, setTags] = useState([]);
const [filter, setFilter] = useState({ account_id: "", type: "" });
const [form, setForm] = useState(null);
const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false);
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
const tagById = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
// Die Tags einer Zeile als Datensaetze – fuer die Anzeige und fuer den
// Spaltenfilter, der mit Namen arbeitet und nicht mit IDs.
const tagsVon = (tx) => (tx.tag_ids || []).map((id) => tagById[id]).filter(Boolean);
const categoryById = useMemo(() => {
const m = {};
for (const g of groups) for (const c of g.categories) m[c.id] = c;
@@ -70,9 +77,12 @@ export default function Transactions() {
async function ladenStamm() {
try {
const [accs, cats] = await Promise.all([api.listAccounts(), api.listCategories()]);
const [accs, cats, tagListe] = await Promise.all([
api.listAccounts(), api.listCategories(), api.listTags(),
]);
setAccounts(accs);
setGroups(cats);
setTags(tagListe);
} catch (err) {
toast(err.message, "error");
}
@@ -120,6 +130,31 @@ export default function Transactions() {
}
}
// Je Zeile eine Warteschlange. Die Auswahl bleibt nach einem Klick offen –
// man vergibt selten genau einen Tag –, also kommen zwei Klicks schnell
// hintereinander. Ohne die Reihung ueberholten sich die beiden Anfragen, und
// welche zuletzt ankam, entschied: Der erste Tag fiel wieder ab.
const tagLauf = useRef({});
function tagsSetzen(tx, ids) {
const vorher = tx.tag_ids || [];
// Sofort anzeigen, nicht erst nach der Antwort: Sonst rechnet der zweite
// Klick noch mit dem Stand VOR dem ersten und nimmt ihn zurueck.
setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: ids } : x)));
const lauf = (tagLauf.current[tx.id] || Promise.resolve())
.catch(() => {})
.then(() => api.updateTransaction(tx.id, { tag_ids: ids.map(Number) }))
// Die Zeile ersetzen statt alles neu zu laden, sonst springt die
// Bildlaufposition bei jedem Klick nach oben.
.then((neu) => setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x))))
.catch((err) => {
setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: vorher } : x)));
toast(err.message, "error");
});
tagLauf.current[tx.id] = lauf;
return lauf;
}
async function verbinden(tx) {
const gegen = vorschlag[tx.id];
if (!gegen) return;
@@ -243,6 +278,7 @@ export default function Transactions() {
payee: tx.payee || "",
description: tx.description || "",
notes: tx.notes || "",
tag_ids: tx.tag_ids || [],
budget_as_expense: tx.budget_treatment !== "neutral",
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
// Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst:
@@ -271,6 +307,7 @@ export default function Transactions() {
payee: form.payee.trim() || null,
description: form.description.trim() || null,
notes: form.notes.trim() || null,
tag_ids: form.tag_ids || [],
budget_period: form.budget_period || null,
};
if (form.type === "transfer") {
@@ -505,6 +542,20 @@ export default function Transactions() {
)
),
},
{
// filterValues statt filterText: Eine Zeile hat MEHRERE Tags, und die
// DataTable kann das – sie vergleicht dann gegen jeden einzelnen Wert.
key: "tags", header: t("tags.column"), width: 170,
filterValues: (tx) => tagsVon(tx).map((x) => x.name),
render: (tx) => (
<TagSelect
tags={tags}
value={tx.tag_ids || []}
onChange={(ids) => tagsSetzen(tx, ids)}
placeholder={t("tags.none")}
/>
),
},
{
key: "account", header: t("transactions.account"), width: 130,
filterText: (tx) => accountById[tx.account_id]?.name || "",
@@ -582,7 +633,7 @@ export default function Transactions() {
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
formatDate, links, paarZu, vorschlag, abos, t]);
formatDate, links, paarZu, vorschlag, abos, tags, tagById, t]);
return (
<div>
@@ -755,6 +806,17 @@ export default function Transactions() {
placeholder={t("transactions.notesPlaceholder")}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</label>
{/* Tags quer zur Kategorie: „Urlaub Island", „absetzbar". Mehrere
duerfen es sein, deshalb keine einfache Auswahl. */}
<label>
<span>{t("tags.column")}</span>
<TagSelect
tags={tags}
value={form.tag_ids}
onChange={(ids) => setForm({ ...form, tag_ids: ids })}
placeholder={t("tags.none")}
/>
</label>
</form>
</Modal>
)}
+5 -3
View File
@@ -225,7 +225,7 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
/* ---------- Forms ---------- */
label { display: block; margin-bottom: var(--sp-4); font-size: 0.8rem; color: var(--muted); font-weight: 580; }
input, select {
input, select, textarea {
width: 100%; margin-top: 5px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
@@ -234,11 +234,13 @@ input, select {
background: var(--surface); color: var(--text);
transition: border-color .12s, box-shadow .12s;
}
input:focus, select:focus {
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
input::placeholder { color: var(--muted); opacity: 0.7; }
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.7; }
/* Nur in der Hoehe ziehbar: seitwaerts sprengte das Feld den Dialog. */
textarea { resize: vertical; min-height: 4.5em; line-height: 1.45; }
/* Ankreuzfelder sind keine Textfelder: die Regel oben (width:100%, margin-top)
zoege sie sonst ueber die volle Breite, das Haekchen landete in der Mitte und
der Text daneben ganz rechts. Hier zentral ausgenommen, damit das nicht an