From 64cbbc8cf68361597ec1d0a5032d5b16c61a322b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 20 Aug 2026 14:19:42 +0200 Subject: [PATCH] feat: Kategorien mit Schlagwoertern sind in der Liste erkennbar Der Schlagwort-Knopf sah bei jeder Kategorie gleich aus - man musste jede einzeln oeffnen, um zu sehen, wo ueberhaupt etwas hinterlegt ist. Belegte Kategorien tragen den Knopf jetzt in Akzentfarbe und mit der Anzahl daneben, der Tooltip nennt sie ausgeschrieben. Die Zahl kommt als keyword_count an der Kategorie mit, ermittelt in einer Aggregat-Abfrage fuer die ganze Liste. Ein Abruf je Kategorie waere dafuer unverhaeltnismaessig gewesen. Gefuellt wird das Feld nur beim Auflisten; beim Anlegen und Aendern einer Gruppe bleibt es bei 0, wo es ohnehin nichts aussagt. Das Popup laedt beim Schliessen die Liste neu, damit die Zahl nach dem Bearbeiten stimmt. Co-Authored-By: Claude Opus 5 --- backend/app/routers/categories.py | 33 ++++++++++++++++++++++++++++--- backend/app/schemas.py | 3 +++ backend/tests/test_keywords.py | 27 +++++++++++++++++++++++++ web/src/finance.css | 18 +++++++++++++++++ web/src/locales/de.json | 2 ++ web/src/locales/en.json | 2 ++ web/src/pages/Categories.jsx | 20 +++++++++++++++++-- 7 files changed, 100 insertions(+), 5 deletions(-) diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index 11ba626..17467e0 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -4,6 +4,7 @@ Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe. """ from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func from sqlalchemy.orm import Session from ..database import get_db @@ -33,8 +34,14 @@ FLAG_MIN_APPLIED = 5 FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte) -def group_out(g: CategoryGroup) -> CategoryGroupOut: +def group_out(g: CategoryGroup, keyword_counts: dict[int, int] | None = None) -> CategoryGroupOut: cats = sorted(g.categories, key=lambda c: (c.position, c.name.lower())) + out = [] + for c in cats: + eintrag = CategoryOut.model_validate(c) + if keyword_counts is not None: + eintrag.keyword_count = keyword_counts.get(c.id, 0) + out.append(eintrag) return CategoryGroupOut( id=g.id, name=g.name, @@ -43,10 +50,29 @@ def group_out(g: CategoryGroup) -> CategoryGroupOut: color=g.color, position=g.position, is_builtin=g.is_builtin, - categories=[CategoryOut.model_validate(c) for c in cats], + categories=out, ) +def _keyword_counts(db: Session, user: User) -> dict[int, int]: + """Schlagwörter je Kategorie – eine Abfrage für die ganze Liste. + + Die Übersicht markiert damit die Kategorien, für die schon etwas hinterlegt + ist; ein Abruf je Kategorie waere dafuer unverhaeltnismaessig.""" + zeilen = ( + db.query(CategorizationRule.category_id, func.count(CategorizationRule.id)) + .filter( + CategorizationRule.owner_id == user.id, + CategorizationRule.source == RuleSource.manual, + CategorizationRule.is_transfer.is_(False), + CategorizationRule.category_id.isnot(None), + ) + .group_by(CategorizationRule.category_id) + .all() + ) + return {cat_id: anzahl for cat_id, anzahl in zeilen} + + def _next_position(db: Session, model, **filters) -> int: q = db.query(model.position) for k, v in filters.items(): @@ -63,7 +89,8 @@ def list_categories(db: Session = Depends(get_db), user: User = Depends(get_curr .order_by(CategoryGroup.position, CategoryGroup.name) .all() ) - return [group_out(g) for g in groups] + counts = _keyword_counts(db, user) + return [group_out(g, counts) for g in groups] # --- Gruppen --------------------------------------------------------------- diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ca86b81..4a1da49 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -209,6 +209,9 @@ class CategoryOut(BaseModel): position: int is_builtin: bool is_archived: bool + # Anzahl hinterlegter Schlagwörter. Nur in der Liste gefüllt, damit die + # Übersicht ohne einen Abruf je Kategorie auskommt. + keyword_count: int = 0 class CategoryGroupOut(BaseModel): diff --git a/backend/tests/test_keywords.py b/backend/tests/test_keywords.py index f46d76e..c2f7ebf 100644 --- a/backend/tests/test_keywords.py +++ b/backend/tests/test_keywords.py @@ -135,6 +135,33 @@ def test_keyword_update_and_conflict(admin): assert admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": "coop"}).status_code == 409 +def test_keyword_count_in_category_list(admin): + """Die Übersicht markiert Kategorien mit Schlagwörtern – dafür die Anzahl. + + Gemessen wird relativ: die Testdatenbank lebt über den ganzen Lauf, in den + Kategorien liegen also schon Wörter aus vorherigen Tests.""" + a, b = _expense_cats(admin, 2) + + def anzahl(cat_id): + for g in admin.get("/api/categories").json(): + for c in g["categories"]: + if c["id"] == cat_id: + return c["keyword_count"] + raise AssertionError("Kategorie nicht gefunden") + + vorher_a, vorher_b = anzahl(a["id"]), anzahl(b["id"]) + # Gegenprobe: die Zahl entspricht dem, was der Einzelabruf liefert. + assert vorher_a == len(admin.get(f"/api/categories/{a['id']}/keywords").json()) + + kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "zaehltest-eins"}).json() + admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "zaehltest-zwei"}) + assert anzahl(a["id"]) == vorher_a + 2 + assert anzahl(b["id"]) == vorher_b # andere Kategorie bleibt unberührt + + admin.delete(f"/api/categories/keywords/{kw['id']}") + assert anzahl(a["id"]) == vorher_a + 1 + + def test_keyword_update_amount(admin): """Wort und Betrag gemeinsam ändern – das Popup lädt beides in die Zeile.""" (a,) = _expense_cats(admin, 1) diff --git a/web/src/finance.css b/web/src/finance.css index d6a8815..bd8dc66 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -772,6 +772,24 @@ tr.row-dup { background: var(--danger); color: #fff; } +/* Schlagwort-Knopf in der Kategorienliste. Belegte Kategorien tragen die + Akzentfarbe und die Anzahl – so sieht man der Liste an, wo schon etwas + hinterlegt ist, ohne jede Kategorie einzeln zu öffnen. */ +.kw-btn { + gap: 5px; +} +.kw-btn.belegt { + color: var(--accent); + border-color: var(--accent-soft); + background: var(--accent-soft); +} +.kw-btn-zahl { + font-size: 0.72rem; + font-weight: 640; + font-variant-numeric: tabular-nums; + line-height: 1; +} + /* Das Schlagwort, das gerade oben in der Zeile liegt. Bearbeitet wird dort, nicht im Chip – im Chip war für den Betrag kein Platz. */ .kw-chip.aktiv { diff --git a/web/src/locales/de.json b/web/src/locales/de.json index a1241bf..b8a6b5d 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -181,6 +181,8 @@ "deleteConfirm": "Diese Kategorie löschen?", "deleteGroupConfirm": "Diese Gruppe samt aller Kategorien löschen?", "keywordsBtn": "Schlagwörter", + "keywordsCount_one": "{{count}} Schlagwort hinterlegt", + "keywordsCount_other": "{{count}} Schlagwörter hinterlegt", "keywordsTitle": "Schlagwörter – {{name}}", "keywordsHint": "Buchungen, die eines dieser Wörter im Empfänger oder Verwendungszweck enthalten, werden bei künftigen Importen automatisch dieser Kategorie zugeordnet.", "keywordAdd": "Wort eingeben und Enter", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c565024..821909b 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -181,6 +181,8 @@ "deleteConfirm": "Delete this category?", "deleteGroupConfirm": "Delete this group and all its categories?", "keywordsBtn": "Keywords", + "keywordsCount_one": "{{count}} keyword set", + "keywordsCount_other": "{{count}} keywords set", "keywordsTitle": "Keywords – {{name}}", "keywordsHint": "Transactions whose payee or description contains one of these words are automatically assigned to this category on future imports.", "keywordAdd": "Type a word and press Enter", diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx index 0ee2556..f9e8c43 100644 --- a/web/src/pages/Categories.jsx +++ b/web/src/pages/Categories.jsx @@ -260,8 +260,17 @@ export default function Categories() {
{c.name} -
); }