diff --git a/backend/app/main.py b/backend/app/main.py index 75ed20e..c7a00b7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 1166588..464dbd1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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"), + ) diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py new file mode 100644 index 0000000..8cae87f --- /dev/null +++ b/backend/app/routers/tags.py @@ -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() diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py index 879d86e..bb0fcf3 100644 --- a/backend/app/routers/transactions.py +++ b/backend/app/routers/transactions.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 3d18fb6..3f2b9fd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): diff --git a/backend/migrations/versions/0038_tags_fuer_buchungen.py b/backend/migrations/versions/0038_tags_fuer_buchungen.py new file mode 100644 index 0000000..2e09caa --- /dev/null +++ b/backend/migrations/versions/0038_tags_fuer_buchungen.py @@ -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") diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py new file mode 100644 index 0000000..918f68f --- /dev/null +++ b/backend/tests/test_tags.py @@ -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" diff --git a/web/src/App.jsx b/web/src/App.jsx index a85c71c..704777d 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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() { + @@ -142,6 +144,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen, stand jede Bank-Buchung ohne Kategorie in beiden Listen. */} diff --git a/web/src/api.js b/web/src/api.js index 13443b8..5d5284f 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -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) => { diff --git a/web/src/components/TagChip.jsx b/web/src/components/TagChip.jsx new file mode 100644 index 0000000..45d5c3b --- /dev/null +++ b/web/src/components/TagChip.jsx @@ -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 ( + + {name} + {onRemove && ( + + )} + + ); +} diff --git a/web/src/components/TagSelect.jsx b/web/src/components/TagSelect.jsx new file mode 100644 index 0000000..2074388 --- /dev/null +++ b/web/src/components/TagSelect.jsx @@ -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 ( +
+ + + {open && pos && createPortal( +
+
+ + setQ(e.target.value)} + placeholder={t("tags.searchPlaceholder")} + /> +
+
+ {sichtbar.map((x) => ( + + ))} + {sichtbar.length === 0 && ( +
+ {tags.length === 0 ? t("tags.empty") : t("tags.noMatch")} +
+ )} +
+
, + document.body, + )} +
+ ); +} diff --git a/web/src/finance.css b/web/src/finance.css index 7ac02d7..c23bfa3 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -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; +} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 804904d..29b3cd4 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0404d12..8a89d1d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/pages/Tags.jsx b/web/src/pages/Tags.jsx new file mode 100644 index 0000000..362ec9a --- /dev/null +++ b/web/src/pages/Tags.jsx @@ -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 ( +
+
+

{t("tags.title")}

+ +
+ +
+

{t("tags.intro")}

+ {tags.map((tag) => ( +
+ + + + +
+ ))} + {tags.length === 0 &&
{t("tags.empty")}
} +
+ + {modal && ( + setModal(null)} + footer={ + <> + + + + } + > +
+ + +
+
+ )} +
+ ); +} diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx index 0595ff0..b26787f 100644 --- a/web/src/pages/Transactions.jsx +++ b/web/src/pages/Transactions.jsx @@ -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) => ( + 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 (
@@ -755,6 +806,17 @@ export default function Transactions() { placeholder={t("transactions.notesPlaceholder")} onChange={(e) => setForm({ ...form, notes: e.target.value })} /> + {/* Tags quer zur Kategorie: „Urlaub Island", „absetzbar". Mehrere + duerfen es sein, deshalb keine einfache Auswahl. */} + )}