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() {