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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-20 14:19:42 +02:00
co-authored by Claude Opus 5
parent df5bfec331
commit 64cbbc8cf6
7 changed files with 100 additions and 5 deletions
+30 -3
View File
@@ -4,6 +4,7 @@ Eine Kategorie erbt ihre Art (Einnahme/Ausgabe) von ihrer Gruppe.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
@@ -33,8 +34,14 @@ FLAG_MIN_APPLIED = 5
FLAG_RATIO_DEN = 3 # >= 1/3 (jede dritte) 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())) 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( return CategoryGroupOut(
id=g.id, id=g.id,
name=g.name, name=g.name,
@@ -43,10 +50,29 @@ def group_out(g: CategoryGroup) -> CategoryGroupOut:
color=g.color, color=g.color,
position=g.position, position=g.position,
is_builtin=g.is_builtin, 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: def _next_position(db: Session, model, **filters) -> int:
q = db.query(model.position) q = db.query(model.position)
for k, v in filters.items(): 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) .order_by(CategoryGroup.position, CategoryGroup.name)
.all() .all()
) )
return [group_out(g) for g in groups] counts = _keyword_counts(db, user)
return [group_out(g, counts) for g in groups]
# --- Gruppen --------------------------------------------------------------- # --- Gruppen ---------------------------------------------------------------
+3
View File
@@ -209,6 +209,9 @@ class CategoryOut(BaseModel):
position: int position: int
is_builtin: bool is_builtin: bool
is_archived: 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): class CategoryGroupOut(BaseModel):
+27
View File
@@ -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 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): def test_keyword_update_amount(admin):
"""Wort und Betrag gemeinsam ändern – das Popup lädt beides in die Zeile.""" """Wort und Betrag gemeinsam ändern – das Popup lädt beides in die Zeile."""
(a,) = _expense_cats(admin, 1) (a,) = _expense_cats(admin, 1)
+18
View File
@@ -772,6 +772,24 @@ tr.row-dup {
background: var(--danger); background: var(--danger);
color: #fff; 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, /* Das Schlagwort, das gerade oben in der Zeile liegt. Bearbeitet wird dort,
nicht im Chip – im Chip war für den Betrag kein Platz. */ nicht im Chip – im Chip war für den Betrag kein Platz. */
.kw-chip.aktiv { .kw-chip.aktiv {
+2
View File
@@ -181,6 +181,8 @@
"deleteConfirm": "Diese Kategorie löschen?", "deleteConfirm": "Diese Kategorie löschen?",
"deleteGroupConfirm": "Diese Gruppe samt aller Kategorien löschen?", "deleteGroupConfirm": "Diese Gruppe samt aller Kategorien löschen?",
"keywordsBtn": "Schlagwörter", "keywordsBtn": "Schlagwörter",
"keywordsCount_one": "{{count}} Schlagwort hinterlegt",
"keywordsCount_other": "{{count}} Schlagwörter hinterlegt",
"keywordsTitle": "Schlagwörter – {{name}}", "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.", "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", "keywordAdd": "Wort eingeben und Enter",
+2
View File
@@ -181,6 +181,8 @@
"deleteConfirm": "Delete this category?", "deleteConfirm": "Delete this category?",
"deleteGroupConfirm": "Delete this group and all its categories?", "deleteGroupConfirm": "Delete this group and all its categories?",
"keywordsBtn": "Keywords", "keywordsBtn": "Keywords",
"keywordsCount_one": "{{count}} keyword set",
"keywordsCount_other": "{{count}} keywords set",
"keywordsTitle": "Keywords – {{name}}", "keywordsTitle": "Keywords – {{name}}",
"keywordsHint": "Transactions whose payee or description contains one of these words are automatically assigned to this category on future imports.", "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", "keywordAdd": "Type a word and press Enter",
+18 -2
View File
@@ -260,8 +260,17 @@ export default function Categories() {
<div className="cat-row" key={c.id}> <div className="cat-row" key={c.id}>
<IconChip name={c.icon} color={c.color} size={26} /> <IconChip name={c.icon} color={c.color} size={26} />
<span className="grow">{c.name}</span> <span className="grow">{c.name}</span>
<button className="btn sm ghost" title={t("categories.keywordsBtn")} onClick={() => setKeywordCat(c)}> {/* Belegte Kategorien heben sich ab: Akzentfarbe plus Anzahl.
Sonst sähe man der Liste nicht an, wo schon etwas hinterlegt ist. */}
<button
className={c.keyword_count ? "btn sm ghost kw-btn belegt" : "btn sm ghost kw-btn"}
title={c.keyword_count
? t("categories.keywordsCount", { count: c.keyword_count })
: t("categories.keywordsBtn")}
onClick={() => setKeywordCat(c)}
>
<Icon name="tags" size={14} /> <Icon name="tags" size={14} />
{c.keyword_count ? <span className="kw-btn-zahl">{c.keyword_count}</span> : null}
</button> </button>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}> <button className="btn sm ghost" title={t("common.edit")} onClick={() => openCategory(g.id, c)}>
<Icon name="edit" size={14} /> <Icon name="edit" size={14} />
@@ -328,7 +337,14 @@ export default function Categories() {
</Modal> </Modal>
)} )}
{keywordCat && <KeywordModal category={keywordCat} onClose={() => setKeywordCat(null)} />} {/* Beim Schliessen neu laden: im Popup kann sich die Anzahl der
Schlagwörter geändert haben, und die steht am Knopf. */}
{keywordCat && (
<KeywordModal
category={keywordCat}
onClose={() => { setKeywordCat(null); laden(); }}
/>
)}
</div> </div>
); );
} }