Compare commits

..
5 Commits
Author SHA1 Message Date
GuidoandClaude Opus 4.8 1d5b23c127 feat: Excel-artige DataTable (Spaltenbreite/Sortieren/Filtern) für alle Tabellen
- Portierte DataTable-Komponente (aus Vorrania): Spalten per Drag breiter ziehen
  und verschieben, Klick-Sortierung, Filterzeile je Spalte, Spalten ein-/ausblenden;
  Breiten/Reihenfolge/Filter pro Tabelle im Browser gemerkt (localStorage)
- Zu langer Zellinhalt wird mit „…" abgeschnitten (Titel bleibt sichtbar, Excel-artig)
- Umgestellt: Buchungen, Import-Prüfansicht (inkl. interaktiver Zellen), Import-
  Verlauf, Benutzer
- Neue Skeleton-Komponente für den Ladezustand

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 22:58:01 +02:00
GuidoandClaude Opus 4.8 e02ef7eb8f style: Dark-Theme grauer (weniger Grünanteil in den Neutraltönen)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 22:47:22 +02:00
GuidoandClaude Opus 4.8 468e76ef4c style: Dark-Theme in dunkles Grün (Hintergrund + Sidebar ohne Blaustich)
Neutraltöne (bg/surface/border/text/muted) hell wie dunkel leicht grün getönt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 22:43:31 +02:00
GuidoandClaude Opus 4.8 204a24d0df feat: Regeln (Betrag/Transfer/Konto-Texte) + Münz-Icon + pastellgrünes Theme
Regeln:
- Schlagwörter mit optionaler Betragsbedingung (z. B. „SV = 5.00 → Restaurant");
  Betrags-Regeln gewinnen vor betragslosen
- Transfer-Schlagwörter (Text → Transfer) auf der Konten-Seite
- Konto-Identifikator-Art „Text": erkennt das Zielkonto eines Transfers aus dem
  Buchungstext (längster passender Text gewinnt)
- Migration 0007, Endpunkte, Tests (Betrag/Transfer/Konflikt), gesamt 30 grün

Optik:
- App-Icon: Münzhaufen (aus Nutzer-SVG) weiß auf grünem Grund, klein gehalten
- Farbschema auf pastelliges Grün (Akzent hell/dunkel + Favicon)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 22:41:38 +02:00
GuidoandClaude Opus 4.8 30bc037579 feat: App-Icon auf Münzen (weg vom Aktien-Look)
BrandMark + favicon.svg zeigen jetzt zwei Münzen statt der Trendlinie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 22:23:01 +02:00
21 changed files with 1102 additions and 296 deletions
+5
View File
@@ -66,6 +66,7 @@ class IdentifierKind(str, enum.Enum):
iban = "iban" iban = "iban"
account_no = "account_no" account_no = "account_no"
card_no = "card_no" card_no = "card_no"
text = "text" # freier Erkennungs-Text im Buchungstext (Zielkonto bei Transfers)
class CategoryKind(str, enum.Enum): class CategoryKind(str, enum.Enum):
@@ -491,6 +492,10 @@ class CategorizationRule(Base):
category_id: Mapped[int | None] = mapped_column( category_id: Mapped[int | None] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=True ForeignKey("categories.id", ondelete="CASCADE"), nullable=True
) )
# Optionale Zusatzbedingung: Regel greift nur bei genau diesem (absoluten) Betrag.
amount_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Wenn True: passende Buchung wird zum Transfer (category_id bleibt leer).
is_transfer: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned) source: Mapped[RuleSource] = mapped_column(_enum(RuleSource), nullable=False, default=RuleSource.learned)
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+9 -4
View File
@@ -7,16 +7,21 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, get_owned from ..deps import get_current_user, get_owned
from ..models import Account, AccountIdentifier, User from ..models import Account, AccountIdentifier, IdentifierKind, User
from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate from ..schemas import AccountCreate, AccountIdentifierOut, AccountOut, AccountUpdate
from ..services import balances from ..services import balances
from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/accounts", tags=["accounts"]) router = APIRouter(prefix="/accounts", tags=["accounts"])
def normalize_identifier(value: str) -> str: def normalize_identifier(value: str, kind: IdentifierKind) -> str:
"""Vereinheitlicht IBAN/Nummer für den Abgleich: Großbuchstaben, nur alphanumerisch.""" """Vereinheitlicht einen Identifikator für den Abgleich. IBAN/Nummer:
Großbuchstaben, nur alphanumerisch. Freier Text: klein, getrimmt (für die
Zielkonto-Erkennung im Buchungstext)."""
if kind == IdentifierKind.text:
return normalize_text(value)
return "".join(ch for ch in value.upper() if ch.isalnum()) return "".join(ch for ch in value.upper() if ch.isalnum())
@@ -49,7 +54,7 @@ def _set_identifiers(acc: Account, items) -> None:
acc.identifiers.clear() acc.identifiers.clear()
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
for it in items: for it in items:
norm = normalize_identifier(it.value) norm = normalize_identifier(it.value, it.kind)
key = (it.kind.value, norm) key = (it.kind.value, norm)
if not norm or key in seen: if not norm or key in seen:
continue continue
+85 -36
View File
@@ -20,6 +20,8 @@ from ..schemas import (
KeywordCreate, KeywordCreate,
KeywordOut, KeywordOut,
KeywordUpdate, KeywordUpdate,
TransferKeywordCreate,
TransferKeywordOut,
) )
from ..services.csv_import import normalize_text from ..services.csv_import import normalize_text
@@ -168,7 +170,7 @@ def delete_category(
def _keyword_out(rule: CategorizationRule) -> KeywordOut: def _keyword_out(rule: CategorizationRule) -> KeywordOut:
return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern) return KeywordOut(id=rule.id, category_id=rule.category_id, word=rule.pattern, amount_cents=rule.amount_cents)
def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationRule: def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationRule:
@@ -178,6 +180,30 @@ def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationR
return rule return rule
def _conflict(db: Session, clash: CategorizationRule) -> HTTPException:
"""Klare Meldung, wenn ein Schlagwort schon woanders (Kategorie/Transfer) hängt."""
if clash.is_transfer:
wo = "als Transfer-Regel"
else:
cat = db.get(Category, clash.category_id)
wo = f"der Kategorie „{cat.name if cat else '?'}“"
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schlagwort ist bereits {wo} zugeordnet.",
)
def _find_any_rule(db: Session, user: User, pattern: str, exclude_id: int | None = None):
q = db.query(CategorizationRule).filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
)
if exclude_id is not None:
q = q.filter(CategorizationRule.id != exclude_id)
return q.first()
@router.get("/keywords/flagged", response_model=list[FlaggedKeywordOut]) @router.get("/keywords/flagged", response_model=list[FlaggedKeywordOut])
def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)): def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""Manuelle Schlagwörter, die auffällig oft wegkorrigiert werden.""" """Manuelle Schlagwörter, die auffällig oft wegkorrigiert werden."""
@@ -196,7 +222,8 @@ def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(ge
names = {c.id: c.name for c in db.query(Category).filter(Category.owner_id == user.id)} names = {c.id: c.name for c in db.query(Category).filter(Category.owner_id == user.id)}
return [ return [
FlaggedKeywordOut( FlaggedKeywordOut(
id=r.id, category_id=r.category_id, category_name=names.get(r.category_id, "?"), id=r.id, category_id=r.category_id,
category_name="→ Transfer" if r.is_transfer else names.get(r.category_id, "?"),
word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count, word=r.pattern, applied_count=r.applied_count, overridden_count=r.overridden_count,
) )
for r in rules for r in rules
@@ -225,27 +252,16 @@ def update_keyword(
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
if pattern != rule.pattern: if pattern != rule.pattern:
clash = ( clash = _find_any_rule(db, user, pattern, exclude_id=rule.id)
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
CategorizationRule.id != rule.id,
)
.first()
)
if clash is not None: if clash is not None:
other = db.get(Category, clash.category_id) raise _conflict(db, clash)
other_name = other.name if other else "?"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.",
)
rule.pattern = pattern rule.pattern = pattern
rule.applied_count = 0 rule.applied_count = 0
rule.overridden_count = 0 rule.overridden_count = 0
rule.confirmed = False rule.confirmed = False
# Betrag nur ändern, wenn mitgeschickt (leere Inline-Wortänderung darf ihn nicht löschen).
if "amount_cents" in payload.model_fields_set:
rule.amount_cents = payload.amount_cents
db.commit() db.commit()
db.refresh(rule) db.refresh(rule)
return _keyword_out(rule) return _keyword_out(rule)
@@ -285,29 +301,62 @@ def add_keyword(
pattern = normalize_text(payload.word)[:255] pattern = normalize_text(payload.word)[:255]
if not pattern: if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
existing = ( existing = _find_any_rule(db, user, pattern)
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
)
.first()
)
if existing is not None: if existing is not None:
if existing.category_id == cat.id: if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
return _keyword_out(existing) # schon vorhanden – unverändert zurück return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
other = db.get(Category, existing.category_id) raise _conflict(db, existing)
other_name = other.name if other else "?"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Schlagwort ist bereits der Kategorie „{other_name}“ zugeordnet.",
)
rule = CategorizationRule( rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern, owner_id=user.id, match_field=MatchField.any, pattern=pattern,
category_id=cat.id, source=RuleSource.manual, priority=10, category_id=cat.id, amount_cents=payload.amount_cents,
source=RuleSource.manual, priority=10,
) )
db.add(rule) db.add(rule)
db.commit() db.commit()
db.refresh(rule) db.refresh(rule)
return _keyword_out(rule) return _keyword_out(rule)
# --- Transfer-Schlagwörter (Text -> Transfer, Zielkonto aus Konto-Texten) ------
def _transfer_keyword_out(rule: CategorizationRule) -> TransferKeywordOut:
return TransferKeywordOut(id=rule.id, word=rule.pattern, amount_cents=rule.amount_cents)
@router.get("/transfer-keywords", response_model=list[TransferKeywordOut])
def list_transfer_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
rules = (
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.source == RuleSource.manual,
CategorizationRule.is_transfer.is_(True),
)
.order_by(CategorizationRule.pattern)
.all()
)
return [_transfer_keyword_out(r) for r in rules]
@router.post("/transfer-keywords", response_model=TransferKeywordOut, status_code=status.HTTP_201_CREATED)
def add_transfer_keyword(
payload: TransferKeywordCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)
):
pattern = normalize_text(payload.word)[:255]
if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
existing = _find_any_rule(db, user, pattern)
if existing is not None:
if existing.is_transfer and existing.amount_cents == payload.amount_cents:
return _transfer_keyword_out(existing)
raise _conflict(db, existing)
rule = CategorizationRule(
owner_id=user.id, match_field=MatchField.any, pattern=pattern,
category_id=None, amount_cents=payload.amount_cents, is_transfer=True,
source=RuleSource.manual, priority=10,
)
db.add(rule)
db.commit()
db.refresh(rule)
return _transfer_keyword_out(rule)
+15 -1
View File
@@ -352,21 +352,35 @@ class ImportCommitIn(BaseModel):
class KeywordCreate(BaseModel): class KeywordCreate(BaseModel):
word: str = Field(min_length=1, max_length=255) word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class KeywordUpdate(BaseModel): class KeywordUpdate(BaseModel):
word: str = Field(min_length=1, max_length=255) word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class KeywordOut(BaseModel): class KeywordOut(BaseModel):
id: int id: int
category_id: int category_id: int
word: str word: str
amount_cents: int | None = None
class TransferKeywordCreate(BaseModel):
word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class TransferKeywordOut(BaseModel):
id: int
word: str
amount_cents: int | None = None
class FlaggedKeywordOut(BaseModel): class FlaggedKeywordOut(BaseModel):
id: int id: int
category_id: int category_id: int | None = None
category_name: str category_name: str
word: str word: str
applied_count: int applied_count: int
+58 -24
View File
@@ -19,6 +19,7 @@ from ..models import (
CategorizationRule, CategorizationRule,
Category, Category,
CategoryKind, CategoryKind,
IdentifierKind,
ImportBatch, ImportBatch,
ImportStatus, ImportStatus,
ImportTemplate, ImportTemplate,
@@ -146,9 +147,10 @@ def import_hash(account_id: int, d: date | None, amount_cents: int | None, payee
def _rule_sort_key(rule: CategorizationRule) -> tuple: def _rule_sort_key(rule: CategorizationRule) -> tuple:
"""Reihenfolge der Regelanwendung: längstes Schlagwort zuerst, dann manuelle """Reihenfolge der Regelanwendung: Betrags-Regeln (spezifischer) zuerst, dann
vor gelernten, dann höhere Priorität/mehr Treffer.""" längstes Schlagwort, dann manuelle vor gelernten, dann Priorität/Treffer."""
return ( return (
1 if rule.amount_cents is not None else 0,
len(rule.pattern or ""), len(rule.pattern or ""),
1 if rule.source == RuleSource.manual else 0, 1 if rule.source == RuleSource.manual else 0,
rule.priority, rule.priority,
@@ -156,32 +158,55 @@ def _rule_sort_key(rule: CategorizationRule) -> tuple:
) )
def _apply_rules( def _pattern_hit(rule: CategorizationRule, p: str, z: str) -> bool:
if rule.match_field == MatchField.payee:
return rule.pattern in p
if rule.match_field == MatchField.purpose:
return rule.pattern in z
return rule.pattern in p or rule.pattern in z # any: Empfänger ODER Zweck
def _match_rule(
rules: list[CategorizationRule], rules: list[CategorizationRule],
payee: str, payee: str,
purpose: str, purpose: str,
amount: int | None,
allowed_kind: CategoryKind | None, allowed_kind: CategoryKind | None,
cat_kind: dict[int, CategoryKind], cat_kind: dict[int, CategoryKind],
) -> tuple[int | None, int | None]: ) -> CategorizationRule | None:
"""Erste passende Regel -> (category_id, rule_id). Nur Kategorien, deren Art """Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
(Einnahme/Ausgabe) zur Buchung passt, damit ein Ausgabe-Schlagwort keine greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
Einnahme falsch einordnet.""" Transfer-Regeln kennen keine Art."""
p = normalize_text(payee) p = normalize_text(payee)
z = normalize_text(purpose) z = normalize_text(purpose)
for rule in rules: # bereits sortiert (längstes/​manuell zuerst) absamt = abs(amount) if amount is not None else None
if not rule.pattern or not rule.category_id: for rule in rules: # bereits sortiert (Betrag/längstes/manuell zuerst)
if not rule.pattern:
continue
if rule.amount_cents is not None and (absamt is None or absamt != rule.amount_cents):
continue
if rule.is_transfer:
if _pattern_hit(rule, p, z):
return rule
continue
if not rule.category_id:
continue continue
if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind: if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind:
continue continue
if rule.match_field == MatchField.payee: if _pattern_hit(rule, p, z):
hit = rule.pattern in p return rule
elif rule.match_field == MatchField.purpose: return None
hit = rule.pattern in z
else: # any: Empfänger ODER Zweck
hit = rule.pattern in p or rule.pattern in z def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
if hit: """Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
return rule.category_id, rule.id Erkennungs-Text je Konto gewinnt."""
return None, None hay = f"{normalize_text(payee)} {normalize_text(purpose)}"
best: tuple[int, int] | None = None # (Länge, Konto-ID)
for val, acc_id in text_idents:
if val and acc_id != source_id and val in hay and (best is None or len(val) > best[0]):
best = (len(val), acc_id)
return best[1] if best else None
def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]: def parse_file_to_candidates(data: bytes, template: ImportTemplate) -> list[dict]:
@@ -223,12 +248,16 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat. """Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik).""" Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
idents: dict[str, int] = {} idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
for value_norm, acc_id in ( text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id) for value_norm, acc_id, kind in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
.join(Account, Account.id == AccountIdentifier.account_id) .join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id) .filter(Account.owner_id == user.id)
): ):
if kind == IdentifierKind.text:
text_idents.append((value_norm, acc_id))
else:
idents[value_norm] = acc_id idents[value_norm] = acc_id
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all() rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
@@ -266,9 +295,14 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
to_account_id = other to_account_id = other
else: else:
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
category_id, matched_rule_id = _apply_rules( rule = _match_rule(rules, payee or "", purpose or "", amount, kind, cat_kind)
rules, payee or "", purpose or "", kind, cat_kind if rule is not None:
) matched_rule_id = rule.id
if rule.is_transfer:
ttype = "transfer"
to_account_id = _guess_target_account(text_idents, payee or "", purpose or "", account.id)
else:
category_id = rule.category_id
h = import_hash(account.id, d, amount, payee) h = import_hash(account.id, d, amount, payee)
is_dup = h in existing or h in seen is_dup = h in existing or h in seen
@@ -0,0 +1,30 @@
"""Regeln: optionale Betragsbedingung + Transfer-Regeln.
Erlaubt Schlagwörter mit fester Betragsbedingung (z. B. „SV = 5.00 → Restaurant")
und Transfer-Regeln (Text → Transfer). Konto-Identifikatoren erlauben zusätzlich
die Art ``text`` (freier Erkennungs-Text) – das ist ein reiner Enum-Wert (VARCHAR),
daher ohne Schemaänderung.
Revision ID: 0007
Revises: 0006
Create Date: 2026-08-19
"""
from alembic import op
import sqlalchemy as sa
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("categorization_rules") as batch:
batch.add_column(sa.Column("amount_cents", sa.Integer(), nullable=True))
batch.add_column(sa.Column("is_transfer", sa.Boolean(), nullable=False, server_default=sa.text("0")))
def downgrade() -> None:
with op.batch_alter_table("categorization_rules") as batch:
batch.drop_column("is_transfer")
batch.drop_column("amount_cents")
+46
View File
@@ -148,6 +148,52 @@ def test_below_threshold_not_flagged(admin):
assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()] assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()]
def test_amount_condition_rule(admin):
giro = _account(admin, name="Giro Amt")
a, _ = _expense_cats(admin, 2)
# „sv" nur bei genau 5.00 -> Kategorie a
r = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "sv", "amount_cents": 500})
assert r.status_code == 201, r.text
assert r.json()["amount_cents"] == 500
csv = (
"Datum;Betrag;Empfaenger;Zweck\n"
"01.03.2026;-5,00;SV Restaurant;Mittag\n" # 5.00 -> a
"02.03.2026;-8,00;SV Restaurant;Produkt\n" # anderer Betrag -> keine Regel
)
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
assert rows[0]["category_id"] == a["id"]
assert rows[1]["category_id"] is None
def test_transfer_rule_and_target_guess(admin):
giro = _account(admin, name="Giro TR")
spar = _account(admin, name="Sparkonto TR", type="sparkonto",
identifiers=[{"kind": "text", "value": "Sparen"}])
assert any(i["kind"] == "text" for i in spar["identifiers"])
r = admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
assert r.status_code == 201, r.text
csv = (
"Datum;Betrag;Empfaenger;Zweck\n"
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # -> Transfer, Ziel = Sparkonto
"02.03.2026;-50,00;Max;Uebertrag woandershin\n" # -> Transfer, kein Ziel erkannt
)
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
assert rows[0]["type"] == "transfer"
assert rows[0]["to_account_id"] == spar["id"]
assert rows[1]["type"] == "transfer"
assert rows[1]["to_account_id"] is None
def test_transfer_keyword_conflicts_with_category_keyword(admin):
a, _ = _expense_cats(admin, 2)
admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "doppelwort"})
# gleiches Wort als Transfer-Regel -> Konflikt
assert admin.post("/api/categories/transfer-keywords", json={"word": "doppelwort"}).status_code == 409
def test_no_retroactive_apply(admin): def test_no_retroactive_apply(admin):
giro = _account(admin, name="Giro Retro") giro = _account(admin, name="Giro Retro")
a, _ = _expense_cats(admin, 2) a, _ = _expense_cats(admin, 2)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 11 KiB

+6 -3
View File
@@ -141,13 +141,16 @@ export const api = {
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
// Schlagwörter je Kategorie (manuelle Auto-Zuordnung) + Prüfmeldungen // Schlagwörter je Kategorie (manuelle Auto-Zuordnung, optional mit Betrag) + Prüfmeldungen
listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`), listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`),
addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }), addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }),
updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }), updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }),
deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }), deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }),
flaggedKeywords: () => request("/categories/keywords/flagged"), flaggedKeywords: () => request("/categories/keywords/flagged"),
confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }), confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }),
// Transfer-Schlagwörter (Text -> Transfer, Zielkonto über Konto-Texte)
listTransferKeywords: () => request("/categories/transfer-keywords"),
addTransferKeyword: (body) => request("/categories/transfer-keywords", { method: "POST", body }),
// Transaktionen // Transaktionen
listTransactions: (params = {}) => { listTransactions: (params = {}) => {
+6 -18
View File
@@ -1,29 +1,17 @@
import { APP_NAME } from "../brand"; import { APP_NAME } from "../brand";
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG // Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist der Münzhaufen aus
// (aufsteigende Trendlinie) im Akzentton – bewusst kein Emoji. // public/favicon.svg (identisch zum Browser-Tab-Icon) – bewusst kein Emoji.
export default function BrandMark({ showName = true, size = 26 }) { export default function BrandMark({ showName = true, size = 26 }) {
return ( return (
<div className="brand-mark"> <div className="brand-mark">
<svg <img
className="brand-logo"
src="/favicon.svg"
width={size} width={size}
height={size} height={size}
viewBox="0 0 32 32" alt={APP_NAME}
role="img"
aria-label={APP_NAME}
focusable="false"
>
<rect x="1" y="1" width="30" height="30" rx="8" fill="var(--accent)" />
<path
d="M8 21 L14 15 L18 18 L24 10"
fill="none"
stroke="#ffffff"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
/> />
<circle cx="24" cy="10" r="2.1" fill="#ffffff" />
</svg>
{showName && <span className="brand-name">{APP_NAME}</span>} {showName && <span className="brand-name">{APP_NAME}</span>}
</div> </div>
); );
+416
View File
@@ -0,0 +1,416 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { Skeleton } from "./Skeleton";
/**
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
* Spalte (Freitext + Dropdown der vorhandenen Werte, über Spalten kombinierbar),
* Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung.
* Reihenfolge + Breiten merkt sich der Browser (localStorage, je `id`).
*
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
* fixed:true (kein Reorder/Resize), width, min }.
*/
const DEFAULT_WIDTH = 150;
function loadPersisted(id) {
try { return JSON.parse(localStorage.getItem(`datatable:${id}`)) || {}; }
catch { return {}; }
}
function savePersisted(id, data) {
try { localStorage.setItem(`datatable:${id}`, JSON.stringify(data)); } catch { /* ignore */ }
}
export default function DataTable({
id, columns, rows, getRowKey, rowClassName,
empty = "Nichts gefunden.", loading = false, toolbarExtra = null,
onVisibleRows = null, onRowClick = null,
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
const [order, setOrder] = useState(() => {
const p = loadPersisted(id);
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
return [...saved, ...rest];
});
const schluessel = columns.map((c) => c.key).join("|");
useEffect(() => {
setOrder((alt) => {
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
return fehlend.length ? [...alt, ...fehlend] : alt;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schluessel]);
const [widths, setWidths] = useState(() => loadPersisted(id).widths || {});
const [filters, setFilters] = useState(() => {
const saved = loadPersisted(id).filters || {};
const out = {};
for (const [k, v] of Object.entries(saved)) {
if (!colByKey[k] || !v) continue;
out[k] = { text: v.text || "", values: Array.isArray(v.values) ? new Set(v.values) : null };
}
return out;
}); // { key: { text, values:Set|null } }
const [sort, setSort] = useState(() => {
const s = loadPersisted(id).sort;
return s && s.key && colByKey[s.key] ? s : null;
}); // { key, dir }
const [filterShown, setFilterShown] = useState(true);
const [openFilter, setOpenFilter] = useState(null);
const [hidden, setHidden] = useState(
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
);
const [chooserOpen, setChooserOpen] = useState(false);
useEffect(() => {
const filtersSer = {};
for (const [k, v] of Object.entries(filters)) {
if (!v || (!v.text && v.values == null)) continue;
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
}
savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort });
}, [id, order, widths, hidden, filters, sort]);
const wrapRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
useLayoutEffect(() => {
const el = wrapRef.current;
if (!el) return undefined;
const ro = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width));
ro.observe(el);
setContainerWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
const allOrdered = order.map((k) => colByKey[k]).filter(Boolean);
const orderedCols = allOrdered.filter((c) => !hidden.has(c.key));
const chooserBtn = useRef(null);
const chooserPop = useRef(null);
const [chooserPos, setChooserPos] = useState(null);
useLayoutEffect(() => {
if (chooserOpen && chooserBtn.current) {
const r = chooserBtn.current.getBoundingClientRect();
setChooserPos({
left: Math.min(r.left, window.innerWidth - 240),
top: r.bottom + 4,
maxHeight: Math.max(180, window.innerHeight - r.bottom - 24),
});
}
}, [chooserOpen]);
useEffect(() => {
if (!chooserOpen) return undefined;
function onDoc(e) {
if (chooserBtn.current?.contains(e.target) || chooserPop.current?.contains(e.target)) return;
setChooserOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [chooserOpen]);
function toggleHidden(key) {
setHidden((s) => {
const n = new Set(s);
if (n.has(key)) { n.delete(key); return n; }
if (allOrdered.filter((c) => !n.has(c.key) && c.key !== key).length === 0) return s;
n.add(key);
return n;
});
}
const isGrow = (c) => c.grow && widths[c.key] == null;
const fixedWidth = (c) => widths[c.key] ?? c.width ?? DEFAULT_WIDTH;
const growCols = orderedCols.filter(isGrow);
const sumFixed = orderedCols.filter((c) => !isGrow(c)).reduce((s, c) => s + fixedWidth(c), 0);
const remaining = containerWidth - sumFixed - 2;
const widthOf = (c) => (isGrow(c)
? Math.max(c.min ?? 160, growCols.length ? remaining / growCols.length : 160)
: fixedWidth(c));
const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0));
const rowValues = (c, r) => (c.filterValues ? c.filterValues(r) : [String(c.filterText(r) ?? "")]);
const distinctByKey = useMemo(() => {
const m = {};
for (const c of columns) {
if (!c.filterText && !c.filterValues) continue;
const set = new Set();
for (const r of rows) for (const v of rowValues(c, r)) set.add(String(v));
m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de"));
}
return m;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columns, rows]);
const flat = useMemo(() => {
let out = rows.filter((r) => orderedCols.every((c) => {
const f = filters[c.key];
if (!f || (!c.filterText && !c.filterValues)) return true;
if (f.values && !rowValues(c, r).some((v) => f.values.has(String(v)))) return false;
if (f.text && c.filterText
&& !String(c.filterText(r) ?? "").toLowerCase().includes(f.text.toLowerCase())) return false;
return true;
}));
if (sort) {
const c = colByKey[sort.key];
const val = (r) => (c?.sortValue ? c.sortValue(r) : (c?.filterText?.(r) ?? ""));
out = [...out].sort((a, b) => {
const va = val(a); const vb = val(b);
const cmp = (typeof va === "number" && typeof vb === "number")
? va - vb : String(va).localeCompare(String(vb), "de");
return sort.dir === "desc" ? -cmp : cmp;
});
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, order, filters, sort]);
const displayRows = useMemo(() => flat.map((row) => ({ row })), [flat]);
const onVisibleRef = useRef(onVisibleRows);
onVisibleRef.current = onVisibleRows;
useEffect(() => {
onVisibleRef.current?.(displayRows.map((d) => d.row));
}, [displayRows]);
function toggleSort(c) {
if (!c.sortValue && !c.filterText) return;
setSort((s) => {
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
if (s.dir === "asc") return { key: c.key, dir: "desc" };
return null;
});
}
// --- Resize (Pointer) ---
const resizing = useRef(null);
function startResize(e, c) {
e.preventDefault(); e.stopPropagation();
const startW = widthOf(c);
setWidths((old) => {
const next = { ...old };
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
return next;
});
resizing.current = { key: c.key, startX: e.clientX, startW };
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", endResize);
}
function onResizeMove(e) {
const r = resizing.current; if (!r) return;
const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX)));
setWidths((old) => ({ ...old, [r.key]: w }));
}
function endResize() {
resizing.current = null;
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", endResize);
}
// --- Reorder (HTML5 Drag) ---
const dragKey = useRef(null);
const [dropKey, setDropKey] = useState(null);
function onDragStart(e, c) {
if (resizing.current) { e.preventDefault(); return; }
dragKey.current = c.key;
e.dataTransfer.effectAllowed = "move";
}
function onDragOver(e, c) { e.preventDefault(); if (c.key !== dropKey) setDropKey(c.key); }
function onDrop(e, c) {
e.preventDefault();
const from = dragKey.current; const to = c.key;
dragKey.current = null; setDropKey(null);
if (!from || from === to) return;
setOrder((ord) => {
const next = ord.filter((k) => k !== from);
next.splice(next.indexOf(to), 0, from);
return next;
});
}
function resetColumns() {
setOrder(columns.map((c) => c.key));
setWidths({});
setHidden(new Set());
}
return (
<div className="dt-root">
<div className="dt-toolbar">
<button type="button" className={`btn sm ${filterShown ? "" : "ghost"}`}
onClick={() => setFilterShown((v) => !v)}>
<Icon name="search" size={14} />Filter
</button>
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
onClick={() => setChooserOpen((o) => !o)}>
<Icon name="settings" size={14} />Spalten
</button>
<button type="button" className="btn sm ghost" onClick={resetColumns}>Spalten zurücksetzen</button>
{toolbarExtra}
</div>
{chooserOpen && chooserPos && createPortal(
<div ref={chooserPop} className="dt-pop" style={{ left: chooserPos.left, top: chooserPos.top }}>
<div className="dt-pop-list" style={{ maxHeight: chooserPos.maxHeight }}>
{allOrdered.map((c) => (
<label key={c.key} className="dt-pop-opt">
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
<span>{c.label || c.header || c.key}</span>
</label>
))}
</div>
</div>,
document.body,
)}
<div className="table-wrap" ref={wrapRef}>
<table className="table datatable" style={{ width: totalWidth ? `${totalWidth}px` : "100%" }}>
<colgroup>
{orderedCols.map((c) => <col key={c.key} style={{ width: `${Math.round(widthOf(c))}px` }} />)}
</colgroup>
<thead>
<tr>
{orderedCols.map((c) => (
<th key={c.key}
className={`dt-th ${c.align === "num" ? "num" : ""} ${dropKey === c.key ? "drop" : ""} ${sort?.key === c.key ? "sorted" : ""}`}
draggable={!c.fixed}
onDragStart={(e) => onDragStart(e, c)}
onDragOver={(e) => onDragOver(e, c)}
onDrop={(e) => onDrop(e, c)}>
<span className="dt-th-label" onClick={() => toggleSort(c)}>
<span className="dt-th-text">{c.header}</span>
{sort?.key === c.key && <span className="dt-sort">{sort.dir === "asc" ? "▲" : "▼"}</span>}
</span>
{!c.fixed && <span className="dt-resize" onPointerDown={(e) => startResize(e, c)} />}
</th>
))}
</tr>
{filterShown && (
<tr className="dt-filter">
{orderedCols.map((c) => (
<td key={c.key}>
{(c.filterText || c.filterValues) ? (
<FilterCell
col={c}
filter={filters[c.key]}
distinct={distinctByKey[c.key] || []}
optionLabel={c.filterOptionLabel}
hasText={Boolean(c.filterText)}
open={openFilter === c.key}
onToggleOpen={setOpenFilter}
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
/>
) : null}
</td>
))}
</tr>
)}
</thead>
<tbody>
{displayRows.map(({ row }) => (
<tr key={getRowKey(row)} className={rowClassName?.(row) || ""}
onClick={onRowClick ? (e) => onRowClick(row, e) : undefined}
style={onRowClick ? { cursor: "pointer" } : undefined}>
{orderedCols.map((c) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>{c.render(row)}</td>
))}
</tr>
))}
{displayRows.length === 0 && (
loading && rows.length === 0 ? (
Array.from({ length: 8 }, (_, r) => (
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
{orderedCols.map((c, ci) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>
<Skeleton
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
/>
</td>
))}
</tr>
))
) : (
<tr><td className="empty" colSpan={orderedCols.length}>
{loading ? "Wird geladen…" : empty}
</td></tr>
)
)}
</tbody>
</table>
</div>
</div>
);
}
/** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */
function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen, optionLabel, hasText = true }) {
const btnRef = useRef(null);
const popRef = useRef(null);
const [pos, setPos] = useState(null);
const [search, setSearch] = useState("");
const text = filter?.text || "";
const values = filter?.values || null; // null = alle erlaubt
useLayoutEffect(() => {
if (open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: Math.min(r.left, window.innerWidth - 260), top: r.bottom + 4 });
}
}, [open]);
useEffect(() => {
if (!open) return undefined;
function onDoc(e) {
if (btnRef.current?.contains(e.target) || popRef.current?.contains(e.target)) return;
onToggleOpen(null);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open, onToggleOpen]);
const shown = distinct.filter((v) => v.toLowerCase().includes(search.toLowerCase()));
const isChecked = (v) => values == null || values.has(v);
const active = Boolean(text) || (values != null && values.size !== distinct.length);
function toggleValue(v) {
const cur = values == null ? new Set(distinct) : new Set(values);
if (cur.has(v)) cur.delete(v); else cur.add(v);
onChange({ text, values: cur.size === distinct.length ? null : cur });
}
function setAll(on) { onChange({ text, values: on ? null : new Set() }); }
return (
<div className="dt-filter-cell">
{hasText && (
<input className="dt-filter-input" placeholder="Filter…" value={text}
onChange={(e) => onChange({ text: e.target.value, values })} />
)}
<button type="button" ref={btnRef} className={`dt-filter-btn ${active ? "active" : ""}`}
title="Werte auswählen" onClick={() => onToggleOpen(open ? null : col.key)}>▾</button>
{open && pos && createPortal(
<div ref={popRef} className="dt-pop" style={{ left: pos.left, top: pos.top }}>
<input className="dt-pop-search" placeholder="Suchen…" value={search}
onChange={(e) => setSearch(e.target.value)} autoFocus />
<div className="dt-pop-actions">
<button type="button" className="link-btn" onClick={() => setAll(true)}>Alle</button>
<button type="button" className="link-btn" onClick={() => setAll(false)}>Keine</button>
</div>
<div className="dt-pop-list">
{shown.map((v) => (
<label key={v} className="dt-pop-opt">
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
<span>{optionLabel ? optionLabel(v) : (v === "" ? "(Leere)" : v)}</span>
</label>
))}
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}
</div>
</div>,
document.body,
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
* durchgehend aria-hidden – Screenreader sollen keine leeren Balken vorlesen.
*/
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
export function Skeleton({ w, h = 14, radius, className = "", style }) {
return (
<span
className={`skeleton ${className}`.trim()}
aria-hidden="true"
style={{ width: w, height: h, borderRadius: radius, ...style }}
/>
);
}
+20
View File
@@ -6,6 +6,10 @@
align-items: center; align-items: center;
gap: var(--sp-2); gap: var(--sp-2);
} }
.brand-logo {
display: block;
flex: 0 0 auto;
}
.brand-name { .brand-name {
font-weight: 650; font-weight: 650;
font-size: 1rem; font-size: 1rem;
@@ -577,6 +581,17 @@ tr.row-dup {
margin-top: var(--sp-2); margin-top: var(--sp-2);
} }
/* DataTable-Zellen einzeilig halten, damit „…" greift (Excel-artig). */
.datatable td { white-space: nowrap; vertical-align: middle; }
.datatable td .named-text { min-width: 0; }
/* Interaktive Zellen (Import-Prüfansicht) füllen ihre Spalte, statt auf 60% zu
schrumpfen (globale .table td select/input-Regel überschreiben). */
.datatable td select,
.datatable td input[inputmode],
.datatable td input[type="text"] { max-width: none; width: 100%; }
.datatable td .catsel { width: 100%; }
.datatable td input[type="checkbox"] { width: auto; }
/* ---------- Kategorie-Auswahl (auf-/zuklappbarer Baum, Ersatz für <select>) ---------- */ /* ---------- Kategorie-Auswahl (auf-/zuklappbarer Baum, Ersatz für <select>) ---------- */
.catsel { .catsel {
width: 100%; width: 100%;
@@ -739,6 +754,11 @@ tr.row-dup {
border: 1px solid var(--border); border: 1px solid var(--border);
font-size: 0.85rem; font-size: 0.85rem;
} }
.kw-amount {
color: var(--muted);
font-variant-numeric: tabular-nums;
font-size: 0.8em;
}
.kw-chip button { .kw-chip button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "Stichtag", "openingDate": "Stichtag",
"identifiers": "Erkennungs-Nummern (IBAN etc.)", "identifiers": "Erkennungs-Nummern (IBAN etc.)",
"addIdentifier": "Nummer hinzufügen", "addIdentifier": "Nummer hinzufügen",
"textIdentPlaceholder": "z. B. Sparkonto, Tagesgeld",
"identShort": "Nr.", "identShort": "Nr.",
"total": "Gesamt", "total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.", "empty": "Noch keine Konten. Lege dein erstes Konto an.",
@@ -144,7 +145,14 @@
"identifierKinds": { "identifierKinds": {
"iban": "IBAN", "iban": "IBAN",
"account_no": "Kontonummer", "account_no": "Kontonummer",
"card_no": "Kartennummer" "card_no": "Kartennummer",
"text": "Text (Erkennung)"
},
"transferRules": {
"title": "Transfer-Erkennung",
"hint": "Buchungen, deren Text eines dieser Wörter enthält, werden als Transfer erkannt. Das Zielkonto wird aus dem Buchungstext über die Erkennungs-Texte je Konto (Konto bearbeiten, Art Text) erraten.",
"wordPlaceholder": "Wort (z. B. Übertrag)",
"empty": "Noch keine Transfer-Schlagwörter."
}, },
"categories": { "categories": {
"title": "Kategorien", "title": "Kategorien",
@@ -163,6 +171,7 @@
"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",
"keywordAmount": "Betrag (optional)",
"keywordEmpty": "Noch keine Schlagwörter.", "keywordEmpty": "Noch keine Schlagwörter.",
"keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.", "keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.",
"searchPlaceholder": "Kategorie suchen …" "searchPlaceholder": "Kategorie suchen …"
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "As of", "openingDate": "As of",
"identifiers": "Identifiers (IBAN etc.)", "identifiers": "Identifiers (IBAN etc.)",
"addIdentifier": "Add identifier", "addIdentifier": "Add identifier",
"textIdentPlaceholder": "e.g. Savings, Deposit",
"identShort": "id(s)", "identShort": "id(s)",
"total": "Total", "total": "Total",
"empty": "No accounts yet. Create your first account.", "empty": "No accounts yet. Create your first account.",
@@ -144,7 +145,14 @@
"identifierKinds": { "identifierKinds": {
"iban": "IBAN", "iban": "IBAN",
"account_no": "Account number", "account_no": "Account number",
"card_no": "Card number" "card_no": "Card number",
"text": "Text (detection)"
},
"transferRules": {
"title": "Transfer detection",
"hint": "Transactions whose text contains one of these words are recognized as a transfer. The target account is guessed from the booking text via each account's detection texts (edit account, kind Text).",
"wordPlaceholder": "Word (e.g. Transfer)",
"empty": "No transfer keywords yet."
}, },
"categories": { "categories": {
"title": "Categories", "title": "Categories",
@@ -163,6 +171,7 @@
"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",
"keywordAmount": "Amount (optional)",
"keywordEmpty": "No keywords yet.", "keywordEmpty": "No keywords yet.",
"keywordEditHint": "Double-click a keyword to edit it.", "keywordEditHint": "Double-click a keyword to edit it.",
"searchPlaceholder": "Search category …" "searchPlaceholder": "Search category …"
+78 -1
View File
@@ -34,6 +34,80 @@ function emptyForm() {
}; };
} }
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
function TransferKeywords() {
const { t } = useTranslation();
const toast = useToast();
const [words, setWords] = useState([]);
const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false);
async function laden() {
try { setWords(await api.listTransferKeywords()); }
catch (err) { toast(err.message, "error"); }
}
useEffect(() => { laden(); }, []);
async function hinzufuegen() {
const w = input.trim();
if (!w) return;
setBusy(true);
try {
const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
await api.addTransferKeyword(body);
setInput("");
setAmount("");
await laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function entfernen(id) {
try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
}
return (
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
<h2>{t("transferRules.title")}</h2>
<p className="muted small">{t("transferRules.hint")}</p>
<div className="kw-add">
<input value={input} placeholder={t("transferRules.wordPlaceholder")}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} />
</button>
</div>
{words.length === 0 ? (
<div className="muted small">{t("transferRules.empty")}</div>
) : (
<div className="kw-chips">
{words.map((w) => (
<span className="kw-chip" key={w.id}>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export default function Accounts() { export default function Accounts() {
const { t } = useTranslation(); const { t } = useTranslation();
const { baseCurrency } = useSettings(); const { baseCurrency } = useSettings();
@@ -165,6 +239,8 @@ export default function Accounts() {
</div> </div>
)} )}
<TransferKeywords />
{form && ( {form && (
<Modal <Modal
title={editing ? t("accounts.edit") : t("accounts.new")} title={editing ? t("accounts.edit") : t("accounts.new")}
@@ -238,9 +314,10 @@ export default function Accounts() {
<option value="iban">IBAN</option> <option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option> <option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option> <option value="card_no">{t("identifierKinds.card_no")}</option>
<option value="text">{t("identifierKinds.text")}</option>
</select> </select>
<input className="grow" style={{ minWidth: 0 }} value={ident.value} <input className="grow" style={{ minWidth: 0 }} value={ident.value}
placeholder="DE.. / Nr." placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
onChange={(e) => { onChange={(e) => {
const next = [...form.identifiers]; const next = [...form.identifiers];
next[idx] = { ...ident, value: e.target.value }; next[idx] = { ...ident, value: e.target.value };
+18 -2
View File
@@ -7,14 +7,17 @@ import Icon from "../components/Icon";
import IconChip from "../components/IconChip"; import IconChip from "../components/IconChip";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker"; import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
import { centsToInput, parseAmountToCents } from "../format";
// Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import // Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import
// gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu. // gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu.
// Optional lässt sich ein fester Betrag als Zusatzbedingung angeben.
function KeywordModal({ category, onClose }) { function KeywordModal({ category, onClose }) {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
const [words, setWords] = useState([]); const [words, setWords] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [editId, setEditId] = useState(null); const [editId, setEditId] = useState(null);
const [editVal, setEditVal] = useState(""); const [editVal, setEditVal] = useState("");
@@ -30,8 +33,12 @@ function KeywordModal({ category, onClose }) {
if (!w) return; if (!w) return;
setBusy(true); setBusy(true);
try { try {
await api.addKeyword(category.id, w); const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
await api.addKeyword(category.id, body);
setInput(""); setInput("");
setAmount("");
await laden(); await laden();
} catch (err) { } catch (err) {
toast(err.message, "error"); toast(err.message, "error");
@@ -61,7 +68,7 @@ function KeywordModal({ category, onClose }) {
const orig = words.find((x) => x.id === id); const orig = words.find((x) => x.id === id);
if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; } if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; }
try { try {
await api.updateKeyword(id, val); await api.updateKeyword(id, { word: val });
abbrechenBearbeiten(); abbrechenBearbeiten();
await laden(); await laden();
} catch (err) { } catch (err) {
@@ -87,6 +94,14 @@ function KeywordModal({ category, onClose }) {
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
/> />
<input
value={amount}
inputMode="decimal"
placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
/>
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}> <button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} /> <Icon name="plus" size={15} />
</button> </button>
@@ -120,6 +135,7 @@ function KeywordModal({ category, onClose }) {
onDoubleClick={() => starteBearbeiten(w)} onDoubleClick={() => starteBearbeiten(w)}
> >
{w.word} {w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}> <button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} /> <Icon name="close" size={13} />
</button> </button>
+119 -78
View File
@@ -6,6 +6,7 @@ import { useConfirm } from "../confirm";
import { useToast } from "../toast"; import { useToast } from "../toast";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable";
import { formatMoney } from "../format"; import { formatMoney } from "../format";
const DATE_FORMATS = [ const DATE_FORMATS = [
@@ -176,12 +177,115 @@ export default function Import() {
const includeCount = rows ? rows.filter((r) => r.include).length : 0; const includeCount = rows ? rows.filter((r) => r.include).length : 0;
function updateRow(idx, patch) { // Aktualisiert eine Zeile über ihren stabilen Erkennungs-Index (die Anzeige in
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); // der Tabelle kann sortiert/gefiltert sein, der Index bleibt gleich).
function updateRow(rowIndex, patch) {
setRows((rs) => rs.map((r) => (r.index === rowIndex ? { ...r, ...patch } : r)));
} }
const acc = accountById[Number(accountId)]; const acc = accountById[Number(accountId)];
const reviewColumns = useMemo(() => [
{
key: "include", header: "", width: 42, fixed: true,
render: (r) => (
<input type="checkbox" checked={!!r.include} disabled={!r.valid}
onChange={(e) => updateRow(r.index, { include: e.target.checked })} />
),
},
{
key: "date", header: t("transactions.date"), width: 112,
sortValue: (r) => r.date || "",
render: (r) => (r.date
? <span className="nowrap">{formatDate(r.date)}</span>
: <span className="amount neg">{t("import.badDate")}</span>),
},
{
key: "amount", header: t("transactions.amount"), align: "num", width: 120,
sortValue: (r) => r.amount_cents || 0,
render: (r) => (
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
</span>
),
},
{
key: "type", header: t("import.typeCol"), width: 128,
filterText: (r) => t(`transactions.${r.type}`),
render: (r) => (
<select value={r.type} onChange={(e) => updateRow(r.index, { type: e.target.value })}>
<option value="expense">{t("transactions.expense")}</option>
<option value="income">{t("transactions.income")}</option>
<option value="transfer">{t("transactions.transfer")}</option>
</select>
),
},
{
key: "payee", header: t("import.payeeCol"), grow: true, min: 200,
filterText: (r) => r.payee || r.purpose || "",
render: (r) => (
<span className="ellipsis" style={{ display: "block" }}>
{r.payee || r.purpose || ""}
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
</span>
),
},
{
key: "category", header: t("transactions.category"), width: 210, min: 160,
render: (r) => (r.type === "transfer" ? (
<select value={r.to_account_id || ""}
onChange={(e) => updateRow(r.index, { to_account_id: e.target.value ? Number(e.target.value) : null })}>
<option value="">{t("import.chooseAccount")}</option>
{accounts.filter((a) => a.id !== Number(accountId)).map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
) : (
<CategorySelect
groups={r.type === "income" ? incomeGroups : expenseGroups}
value={r.category_id || ""}
onChange={(id) => updateRow(r.index, { category_id: id != null ? Number(id) : null })}
allowNone
noneLabel={t("transactions.uncategorized")}
placeholder={t("transactions.uncategorized")}
/>
)),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, formatDate, acc, accounts, accountId, incomeGroups, expenseGroups]);
const historyColumns = useMemo(() => [
{
key: "file", header: t("import.file"), grow: true, min: 180,
filterText: (b) => b.filename || "",
render: (b) => (
<span className="ellipsis" style={{ display: "block" }}>
<strong>{b.filename || "—"}</strong>{" "}
<span className="muted small">{accountById[b.account_id]?.name}</span>
</span>
),
},
{
key: "date", header: t("transactions.date"), width: 150,
sortValue: (b) => b.created_at,
render: (b) => <span className="nowrap">{formatDate(b.created_at)}</span>,
},
{
key: "count", header: t("import.importedCount", { n: "" }).trim(), width: 110, align: "num",
sortValue: (b) => b.imported_count,
render: (b) => <span className="nowrap">{t("import.importedCount", { n: b.imported_count })}</span>,
},
{
key: "action", header: "", width: 120, fixed: true,
render: (b) => (b.status === "committed" ? (
<button className="btn sm danger" onClick={() => undo(b)}>
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
</button>
) : <span className="muted small">{t("import.undone")}</span>),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, formatDate, accountById]);
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -379,65 +483,13 @@ export default function Import() {
<span>{t("import.noneValid")}</span> <span>{t("import.noneValid")}</span>
</div> </div>
)} )}
<div className="table-scroll"> <DataTable
<table className="table"> id="import-review"
<thead> rows={rows}
<tr> getRowKey={(r) => r.index}
<th></th> rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : "")}
<th>{t("transactions.date")}</th> columns={reviewColumns}
<th>{t("transactions.amount")}</th>
<th>{t("import.typeCol")}</th>
<th>{t("import.payeeCol")}</th>
<th>{t("transactions.category")}</th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr key={idx} className={!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : ""}>
<td>
<input type="checkbox" checked={!!r.include} disabled={!r.valid}
onChange={(e) => updateRow(idx, { include: e.target.checked })} style={{ width: "auto" }} />
</td>
<td className="nowrap">{r.date ? formatDate(r.date) : <span className="amount neg">{t("import.badDate")}</span>}</td>
<td className="nowrap">
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
</span>
</td>
<td>
<select value={r.type} onChange={(e) => updateRow(idx, { type: e.target.value })} style={{ minWidth: 110 }}>
<option value="expense">{t("transactions.expense")}</option>
<option value="income">{t("transactions.income")}</option>
<option value="transfer">{t("transactions.transfer")}</option>
</select>
</td>
<td className="ellipsis" style={{ maxWidth: 200 }}>
{r.payee || r.purpose || ""}
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
</td>
<td>
{r.type === "transfer" ? (
<select value={r.to_account_id || ""} onChange={(e) => updateRow(idx, { to_account_id: e.target.value ? Number(e.target.value) : null })} style={{ minWidth: 150 }}>
<option value="">{t("import.chooseAccount")}</option>
{accounts.filter((a) => a.id !== Number(accountId)).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
) : (
<CategorySelect
groups={r.type === "income" ? incomeGroups : expenseGroups}
value={r.category_id || ""}
onChange={(id) => updateRow(idx, { category_id: id != null ? Number(id) : null })}
allowNone
noneLabel={t("transactions.uncategorized")}
placeholder={t("transactions.uncategorized")}
minWidth={180}
/> />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
)} )}
@@ -445,24 +497,13 @@ export default function Import() {
{batches.length > 0 && ( {batches.length > 0 && (
<div className="card"> <div className="card">
<h2>{t("import.history")}</h2> <h2>{t("import.history")}</h2>
<table className="table"> <DataTable
<tbody> id="import-history"
{batches.map((b) => ( rows={batches}
<tr key={b.id} className={b.status === "undone" ? "row-dup" : ""}> getRowKey={(b) => b.id}
<td><strong>{b.filename || "—"}</strong> <span className="muted small">{accountById[b.account_id]?.name}</span></td> rowClassName={(b) => (b.status === "undone" ? "row-dup" : "")}
<td className="nowrap">{formatDate(b.created_at)}</td> columns={historyColumns}
<td className="nowrap">{t("import.importedCount", { n: b.imported_count })}</td> />
<td style={{ textAlign: "right" }}>
{b.status === "committed" ? (
<button className="btn sm danger" onClick={() => undo(b)}>
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
</button>
) : <span className="muted small">{t("import.undone")}</span>}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}
</div> </div>
+64 -49
View File
@@ -9,6 +9,7 @@ import IconChip from "../components/IconChip";
import Money from "../components/Money"; import Money from "../components/Money";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import CategorySelect from "../components/CategorySelect"; import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable";
import { centsToInput, parseAmountToCents, todayIso } from "../format"; import { centsToInput, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"]; const TYPES = ["expense", "income", "transfer"];
@@ -193,6 +194,62 @@ export default function Transactions() {
}; };
} }
const txnColumns = useMemo(() => [
{
key: "date", header: t("transactions.date"), width: 112,
sortValue: (tx) => tx.date,
render: (tx) => <span className="nowrap">{formatDate(tx.date)}</span>,
},
{
key: "category", header: t("transactions.category"), grow: true, min: 200,
filterText: (tx) => rowLabel(tx).title,
sortValue: (tx) => rowLabel(tx).title,
render: (tx) => {
const l = rowLabel(tx);
return (
<div className="named">
<IconChip name={l.icon} color={l.color} size={26} />
<div className="named-text">
<div className="ellipsis">{l.title}</div>
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>}
</div>
</div>
);
},
},
{
key: "account", header: t("transactions.account"), width: 150,
filterText: (tx) => accountById[tx.account_id]?.name || "",
render: (tx) => accountById[tx.account_id]?.name || "",
},
{
key: "amount", header: t("transactions.amount"), align: "num", width: 150,
sortValue: (tx) => tx.amount_base_cents,
render: (tx) => {
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
);
},
},
{
key: "actions", header: "", width: 96, fixed: true,
render: (tx) => (
<div className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</div>
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, formatDate, t]);
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
@@ -220,55 +277,13 @@ export default function Transactions() {
</div> </div>
<div className="card"> <div className="card">
{txns.length === 0 ? ( <DataTable
<div className="empty-state">{t("transactions.empty")}</div> id="transactions"
) : ( rows={txns}
<table className="table"> getRowKey={(tx) => tx.id}
<thead> empty={t("transactions.empty")}
<tr> columns={txnColumns}
<th>{t("transactions.date")}</th> />
<th>{t("transactions.category")}</th>
<th>{t("transactions.account")}</th>
<th style={{ textAlign: "right" }}>{t("transactions.amount")}</th>
<th></th>
</tr>
</thead>
<tbody>
{txns.map((tx) => {
const l = rowLabel(tx);
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<tr key={tx.id}>
<td className="nowrap">{formatDate(tx.date)}</td>
<td>
<div className="named">
<IconChip name={l.icon} color={l.color} size={28} />
<div className="named-text">
<div>{l.title}</div>
{l.sub && <div className="named-sub">{l.sub}</div>}
</div>
</div>
</td>
<td className="nowrap">{acc?.name}</td>
<td style={{ textAlign: "right" }}>
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
</td>
<td className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div> </div>
</> </>
)} )}
+58 -45
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { useToast } from "../toast"; import { useToast } from "../toast";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
function randomPassword(len = 12) { function randomPassword(len = 12) {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
@@ -95,6 +96,55 @@ export default function Users() {
} }
} }
const userColumns = useMemo(() => [
{
key: "name", header: t("users.username"), grow: true, min: 180,
filterText: (u) => u.display_name || u.username,
render: (u) => (
<span className="ellipsis" style={{ display: "block" }}>
<strong>{u.display_name || u.username}</strong>
{u.display_name && <span className="muted small"> · {u.username}</span>}
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
</span>
),
},
{
key: "role", header: t("users.role"), width: 120,
filterText: (u) => (u.role === "admin" ? t("common.admin") : t("common.user")),
render: (u) => <span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>,
},
{
key: "status", header: t("users.status"), width: 120,
filterText: (u) => (u.is_active ? t("users.active") : t("users.inactive")),
render: (u) => (
<span className={`badge ${u.is_active ? "ok" : ""}`}>
{u.is_active ? t("users.active") : t("users.inactive")}
</span>
),
},
{
key: "actions", header: t("common.actions"), width: 132, fixed: true,
render: (u) => (
<div className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
<Icon name="key" size={15} />
</button>
{u.id !== self?.id && (
<>
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
<Icon name="trash" size={15} />
</button>
</>
)}
</div>
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, self]);
return ( return (
<div> <div>
<h1>{t("users.title")}</h1> <h1>{t("users.title")}</h1>
@@ -145,50 +195,13 @@ export default function Users() {
</div> </div>
<div className="card"> <div className="card">
<table className="table"> <DataTable
<thead> id="users"
<tr> rows={users}
<th>{t("users.username")}</th> getRowKey={(u) => u.id}
<th>{t("users.role")}</th> empty={t("users.title")}
<th>{t("users.status")}</th> columns={userColumns}
<th style={{ textAlign: "right" }}>{t("common.actions")}</th> />
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<strong>{u.display_name || u.username}</strong>
{u.display_name && <span className="muted small"> · {u.username}</span>}
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
</td>
<td>
<span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>
</td>
<td>
<span className={`badge ${u.is_active ? "ok" : ""}`}>
{u.is_active ? t("users.active") : t("users.inactive")}
</span>
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
<Icon name="key" size={15} />
</button>
{u.id !== self?.id && (
<>
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
<Icon name="trash" size={15} />
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</div> </div>
); );
+26 -26
View File
@@ -1,14 +1,14 @@
:root { :root {
--bg: #f6f7f9; --bg: #f2f7f4;
--surface: #ffffff; --surface: #ffffff;
--surface-2: #f1f3f5; --surface-2: #eaf2ec;
--border: #e3e6ea; --border: #dde8e1;
--border-strong: #d1d6dc; --border-strong: #c9d6cd;
--text: #1a1f27; --text: #17211b;
--muted: #6b7480; --muted: #6a7c71;
--accent: #3f51b5; --accent: #3f9c72;
--accent-hover: #34429a; --accent-hover: #34825f;
--accent-soft: #eceefb; --accent-soft: #e6f3ec;
--danger: #c0392b; --danger: #c0392b;
--danger-soft: #fbeceb; --danger-soft: #fbeceb;
--warn: #b06f14; --warn: #b06f14;
@@ -536,7 +536,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
} }
.alert .icon { margin-top: 1px; } .alert .icon { margin-top: 1px; }
.alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; } .alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; }
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #cfd6f4; } .alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #c3e6d3; }
.alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; } .alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; }
.alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; } .alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; }
@@ -1001,16 +1001,16 @@ select.zeitraum:hover { color: var(--text); }
/* ---------- Dark theme ---------- */ /* ---------- Dark theme ---------- */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0d1117; --bg: #0f1513;
--surface: #161b22; --surface: #161b18;
--surface-2: #1c2230; --surface-2: #1c2320;
--border: #262d38; --border: #29312d;
--border-strong: #333c4a; --border-strong: #3a443e;
--text: #e6edf3; --text: #e6ece8;
--muted: #8b949e; --muted: #8b968f;
--accent: #7c8ae8; --accent: #6cc39d;
--accent-hover: #93a0ef; --accent-hover: #82d0af;
--accent-soft: #1e2340; --accent-soft: #16291f;
--danger: #f0857a; --danger: #f0857a;
--danger-soft: #2a1a1a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn: #e0a955;
@@ -1022,15 +1022,15 @@ select.zeitraum:hover { color: var(--text); }
.alert.ok { background: #14301f; border-color: #21503a; } .alert.ok { background: #14301f; border-color: #21503a; }
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38; --bg: #0f1513; --surface: #161b18; --surface-2: #1c2320; --border: #29312d;
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8; --border-strong: #3a443e; --text: #e6ece8; --muted: #8b968f; --accent: #6cc39d;
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a; --accent-hover: #82d0af; --accent-soft: #16291f; --danger: #f0857a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c; --warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea; --bg: #f2f7f4; --surface: #ffffff; --surface-2: #eaf2ec; --border: #dde8e1;
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5; --border-strong: #c9d6cd; --text: #17211b; --muted: #6a7c71; --accent: #3f9c72;
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb; --accent-hover: #34825f; --accent-soft: #e6f3ec; --danger: #c0392b; --danger-soft: #fbeceb;
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55; --warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
} }