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"
account_no = "account_no"
card_no = "card_no"
text = "text" # freier Erkennungs-Text im Buchungstext (Zielkonto bei Transfers)
class CategoryKind(str, enum.Enum):
@@ -491,6 +492,10 @@ class CategorizationRule(Base):
category_id: Mapped[int | None] = mapped_column(
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)
priority: 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 ..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 ..services import balances
from ..services.csv_import import normalize_text
from .settings import BASE_CURRENCY_KEY, get_user_setting
router = APIRouter(prefix="/accounts", tags=["accounts"])
def normalize_identifier(value: str) -> str:
"""Vereinheitlicht IBAN/Nummer für den Abgleich: Großbuchstaben, nur alphanumerisch."""
def normalize_identifier(value: str, kind: IdentifierKind) -> str:
"""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())
@@ -49,7 +54,7 @@ def _set_identifiers(acc: Account, items) -> None:
acc.identifiers.clear()
seen: set[tuple[str, str]] = set()
for it in items:
norm = normalize_identifier(it.value)
norm = normalize_identifier(it.value, it.kind)
key = (it.kind.value, norm)
if not norm or key in seen:
continue
+85 -36
View File
@@ -20,6 +20,8 @@ from ..schemas import (
KeywordCreate,
KeywordOut,
KeywordUpdate,
TransferKeywordCreate,
TransferKeywordOut,
)
from ..services.csv_import import normalize_text
@@ -168,7 +170,7 @@ def delete_category(
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:
@@ -178,6 +180,30 @@ def _owned_manual_rule(db: Session, rule_id: int, user: User) -> CategorizationR
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])
def list_flagged_keywords(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
"""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)}
return [
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,
)
for r in rules
@@ -225,27 +252,16 @@ def update_keyword(
if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
if pattern != rule.pattern:
clash = (
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
CategorizationRule.id != rule.id,
)
.first()
)
clash = _find_any_rule(db, user, pattern, exclude_id=rule.id)
if clash is not None:
other = db.get(Category, clash.category_id)
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.",
)
raise _conflict(db, clash)
rule.pattern = pattern
rule.applied_count = 0
rule.overridden_count = 0
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.refresh(rule)
return _keyword_out(rule)
@@ -285,29 +301,62 @@ def add_keyword(
pattern = normalize_text(payload.word)[:255]
if not pattern:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Leeres Schlagwort")
existing = (
db.query(CategorizationRule)
.filter(
CategorizationRule.owner_id == user.id,
CategorizationRule.match_field == MatchField.any,
CategorizationRule.pattern == pattern,
)
.first()
)
existing = _find_any_rule(db, user, pattern)
if existing is not None:
if existing.category_id == cat.id:
return _keyword_out(existing) # schon vorhanden – unverändert zurück
other = db.get(Category, existing.category_id)
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.",
)
if not existing.is_transfer and existing.category_id == cat.id and existing.amount_cents == payload.amount_cents:
return _keyword_out(existing) # schon identisch vorhanden – unverändert zurück
raise _conflict(db, existing)
rule = CategorizationRule(
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.commit()
db.refresh(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):
word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class KeywordUpdate(BaseModel):
word: str = Field(min_length=1, max_length=255)
amount_cents: int | None = Field(default=None, gt=0)
class KeywordOut(BaseModel):
id: int
category_id: int
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):
id: int
category_id: int
category_id: int | None = None
category_name: str
word: str
applied_count: int
+59 -25
View File
@@ -19,6 +19,7 @@ from ..models import (
CategorizationRule,
Category,
CategoryKind,
IdentifierKind,
ImportBatch,
ImportStatus,
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:
"""Reihenfolge der Regelanwendung: längstes Schlagwort zuerst, dann manuelle
vor gelernten, dann höhere Priorität/mehr Treffer."""
"""Reihenfolge der Regelanwendung: Betrags-Regeln (spezifischer) zuerst, dann
längstes Schlagwort, dann manuelle vor gelernten, dann Priorität/Treffer."""
return (
1 if rule.amount_cents is not None else 0,
len(rule.pattern or ""),
1 if rule.source == RuleSource.manual else 0,
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],
payee: str,
purpose: str,
amount: int | None,
allowed_kind: CategoryKind | None,
cat_kind: dict[int, CategoryKind],
) -> tuple[int | None, int | None]:
"""Erste passende Regel -> (category_id, rule_id). Nur Kategorien, deren Art
(Einnahme/Ausgabe) zur Buchung passt, damit ein Ausgabe-Schlagwort keine
Einnahme falsch einordnet."""
) -> CategorizationRule | None:
"""Erste passende Regel (Muster + optionale Betragsbedingung). Kategorie-Regeln
greifen nur, wenn die Kategorie-Art (Einnahme/Ausgabe) zur Buchung passt;
Transfer-Regeln kennen keine Art."""
p = normalize_text(payee)
z = normalize_text(purpose)
for rule in rules: # bereits sortiert (längstes/​manuell zuerst)
if not rule.pattern or not rule.category_id:
absamt = abs(amount) if amount is not None else None
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
if allowed_kind is not None and cat_kind.get(rule.category_id) != allowed_kind:
continue
if rule.match_field == MatchField.payee:
hit = rule.pattern in p
elif rule.match_field == MatchField.purpose:
hit = rule.pattern in z
else: # any: Empfänger ODER Zweck
hit = rule.pattern in p or rule.pattern in z
if hit:
return rule.category_id, rule.id
return None, None
if _pattern_hit(rule, p, z):
return rule
return None
def _guess_target_account(text_idents: list[tuple[str, int]], payee: str, purpose: str, source_id: int) -> int | None:
"""Zielkonto eines Transfers aus dem Buchungstext raten: längster passender
Erkennungs-Text je Konto gewinnt."""
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]:
@@ -223,13 +248,17 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
"""Reichert Kandidaten an: Typ, Auto-Kategorie, interner Transfer, Duplikat.
Wird von CSV- UND Beleg-Import genutzt (gleiche Prüf-/Buchungslogik)."""
idents: dict[str, int] = {}
for value_norm, acc_id in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id)
idents: dict[str, int] = {} # IBAN/Nummer -> Konto (für Transfer via IBAN)
text_idents: list[tuple[str, int]] = [] # freie Erkennungs-Texte -> Konto
for value_norm, acc_id, kind in (
db.query(AccountIdentifier.value_norm, AccountIdentifier.account_id, AccountIdentifier.kind)
.join(Account, Account.id == AccountIdentifier.account_id)
.filter(Account.owner_id == user.id)
):
idents[value_norm] = acc_id
if kind == IdentifierKind.text:
text_idents.append((value_norm, acc_id))
else:
idents[value_norm] = acc_id
rules = db.query(CategorizationRule).filter(CategorizationRule.owner_id == user.id).all()
rules.sort(key=_rule_sort_key, reverse=True) # längstes/​manuell zuerst
@@ -266,9 +295,14 @@ def detect_rows(db: Session, user: User, account: Account, candidates: list[dict
to_account_id = other
else:
kind = CategoryKind.expense if ttype == "expense" else CategoryKind.income
category_id, matched_rule_id = _apply_rules(
rules, payee or "", purpose or "", kind, cat_kind
)
rule = _match_rule(rules, payee or "", purpose or "", amount, 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)
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()]
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):
giro = _account(admin, name="Giro Retro")
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 }),
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`),
addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }),
updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }),
addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }),
updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }),
deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }),
flaggedKeywords: () => request("/categories/keywords/flagged"),
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
listTransactions: (params = {}) => {
+7 -19
View File
@@ -1,29 +1,17 @@
import { APP_NAME } from "../brand";
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG
// (aufsteigende Trendlinie) im Akzentton – bewusst kein Emoji.
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist der Münzhaufen aus
// public/favicon.svg (identisch zum Browser-Tab-Icon) – bewusst kein Emoji.
export default function BrandMark({ showName = true, size = 26 }) {
return (
<div className="brand-mark">
<svg
<img
className="brand-logo"
src="/favicon.svg"
width={size}
height={size}
viewBox="0 0 32 32"
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>
alt={APP_NAME}
/>
{showName && <span className="brand-name">{APP_NAME}</span>}
</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;
gap: var(--sp-2);
}
.brand-logo {
display: block;
flex: 0 0 auto;
}
.brand-name {
font-weight: 650;
font-size: 1rem;
@@ -577,6 +581,17 @@ tr.row-dup {
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>) ---------- */
.catsel {
width: 100%;
@@ -739,6 +754,11 @@ tr.row-dup {
border: 1px solid var(--border);
font-size: 0.85rem;
}
.kw-amount {
color: var(--muted);
font-variant-numeric: tabular-nums;
font-size: 0.8em;
}
.kw-chip button {
display: inline-flex;
align-items: center;
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "Stichtag",
"identifiers": "Erkennungs-Nummern (IBAN etc.)",
"addIdentifier": "Nummer hinzufügen",
"textIdentPlaceholder": "z. B. Sparkonto, Tagesgeld",
"identShort": "Nr.",
"total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
@@ -144,7 +145,14 @@
"identifierKinds": {
"iban": "IBAN",
"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": {
"title": "Kategorien",
@@ -163,6 +171,7 @@
"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",
"keywordAmount": "Betrag (optional)",
"keywordEmpty": "Noch keine Schlagwörter.",
"keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.",
"searchPlaceholder": "Kategorie suchen …"
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "As of",
"identifiers": "Identifiers (IBAN etc.)",
"addIdentifier": "Add identifier",
"textIdentPlaceholder": "e.g. Savings, Deposit",
"identShort": "id(s)",
"total": "Total",
"empty": "No accounts yet. Create your first account.",
@@ -144,7 +145,14 @@
"identifierKinds": {
"iban": "IBAN",
"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": {
"title": "Categories",
@@ -163,6 +171,7 @@
"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",
"keywordAmount": "Amount (optional)",
"keywordEmpty": "No keywords yet.",
"keywordEditHint": "Double-click a keyword to edit it.",
"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() {
const { t } = useTranslation();
const { baseCurrency } = useSettings();
@@ -165,6 +239,8 @@ export default function Accounts() {
</div>
)}
<TransferKeywords />
{form && (
<Modal
title={editing ? t("accounts.edit") : t("accounts.new")}
@@ -238,9 +314,10 @@ export default function Accounts() {
<option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option>
<option value="text">{t("identifierKinds.text")}</option>
</select>
<input className="grow" style={{ minWidth: 0 }} value={ident.value}
placeholder="DE.. / Nr."
placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
onChange={(e) => {
const next = [...form.identifiers];
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 Modal from "../components/Modal";
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
// 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 }) {
const { t } = useTranslation();
const toast = useToast();
const [words, setWords] = useState([]);
const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false);
const [editId, setEditId] = useState(null);
const [editVal, setEditVal] = useState("");
@@ -30,8 +33,12 @@ function KeywordModal({ category, onClose }) {
if (!w) return;
setBusy(true);
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("");
setAmount("");
await laden();
} catch (err) {
toast(err.message, "error");
@@ -61,7 +68,7 @@ function KeywordModal({ category, onClose }) {
const orig = words.find((x) => x.id === id);
if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; }
try {
await api.updateKeyword(id, val);
await api.updateKeyword(id, { word: val });
abbrechenBearbeiten();
await laden();
} catch (err) {
@@ -87,6 +94,14 @@ function KeywordModal({ category, onClose }) {
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>
@@ -120,6 +135,7 @@ function KeywordModal({ category, onClose }) {
onDoubleClick={() => starteBearbeiten(w)}
>
{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>
+120 -79
View File
@@ -6,6 +6,7 @@ import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable";
import { formatMoney } from "../format";
const DATE_FORMATS = [
@@ -176,12 +177,115 @@ export default function Import() {
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
function updateRow(idx, patch) {
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
// Aktualisiert eine Zeile über ihren stabilen Erkennungs-Index (die Anzeige in
// 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 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 (
<div>
<div className="page-head">
@@ -379,65 +483,13 @@ export default function Import() {
<span>{t("import.noneValid")}</span>
</div>
)}
<div className="table-scroll">
<table className="table">
<thead>
<tr>
<th></th>
<th>{t("transactions.date")}</th>
<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>
<DataTable
id="import-review"
rows={rows}
getRowKey={(r) => r.index}
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : "")}
columns={reviewColumns}
/>
</div>
)}
@@ -445,24 +497,13 @@ export default function Import() {
{batches.length > 0 && (
<div className="card">
<h2>{t("import.history")}</h2>
<table className="table">
<tbody>
{batches.map((b) => (
<tr key={b.id} className={b.status === "undone" ? "row-dup" : ""}>
<td><strong>{b.filename || "—"}</strong> <span className="muted small">{accountById[b.account_id]?.name}</span></td>
<td className="nowrap">{formatDate(b.created_at)}</td>
<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>
<DataTable
id="import-history"
rows={batches}
getRowKey={(b) => b.id}
rowClassName={(b) => (b.status === "undone" ? "row-dup" : "")}
columns={historyColumns}
/>
</div>
)}
</div>
+64 -49
View File
@@ -9,6 +9,7 @@ import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable";
import { centsToInput, parseAmountToCents, todayIso } from "../format";
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 (
<div>
<div className="page-head">
@@ -220,55 +277,13 @@ export default function Transactions() {
</div>
<div className="card">
{txns.length === 0 ? (
<div className="empty-state">{t("transactions.empty")}</div>
) : (
<table className="table">
<thead>
<tr>
<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>
)}
<DataTable
id="transactions"
rows={txns}
getRowKey={(tx) => tx.id}
empty={t("transactions.empty")}
columns={txnColumns}
/>
</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 { api, ApiError } from "../api";
import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
function randomPassword(len = 12) {
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 (
<div>
<h1>{t("users.title")}</h1>
@@ -145,50 +195,13 @@ export default function Users() {
</div>
<div className="card">
<table className="table">
<thead>
<tr>
<th>{t("users.username")}</th>
<th>{t("users.role")}</th>
<th>{t("users.status")}</th>
<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>
<DataTable
id="users"
rows={users}
getRowKey={(u) => u.id}
empty={t("users.title")}
columns={userColumns}
/>
</div>
</div>
);
+26 -26
View File
@@ -1,14 +1,14 @@
:root {
--bg: #f6f7f9;
--bg: #f2f7f4;
--surface: #ffffff;
--surface-2: #f1f3f5;
--border: #e3e6ea;
--border-strong: #d1d6dc;
--text: #1a1f27;
--muted: #6b7480;
--accent: #3f51b5;
--accent-hover: #34429a;
--accent-soft: #eceefb;
--surface-2: #eaf2ec;
--border: #dde8e1;
--border-strong: #c9d6cd;
--text: #17211b;
--muted: #6a7c71;
--accent: #3f9c72;
--accent-hover: #34825f;
--accent-soft: #e6f3ec;
--danger: #c0392b;
--danger-soft: #fbeceb;
--warn: #b06f14;
@@ -536,7 +536,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
}
.alert .icon { margin-top: 1px; }
.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.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; }
@@ -1001,16 +1001,16 @@ select.zeitraum:hover { color: var(--text); }
/* ---------- Dark theme ---------- */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--surface: #161b22;
--surface-2: #1c2230;
--border: #262d38;
--border-strong: #333c4a;
--text: #e6edf3;
--muted: #8b949e;
--accent: #7c8ae8;
--accent-hover: #93a0ef;
--accent-soft: #1e2340;
--bg: #0f1513;
--surface: #161b18;
--surface-2: #1c2320;
--border: #29312d;
--border-strong: #3a443e;
--text: #e6ece8;
--muted: #8b968f;
--accent: #6cc39d;
--accent-hover: #82d0af;
--accent-soft: #16291f;
--danger: #f0857a;
--danger-soft: #2a1a1a;
--warn: #e0a955;
@@ -1022,15 +1022,15 @@ select.zeitraum:hover { color: var(--text); }
.alert.ok { background: #14301f; border-color: #21503a; }
}
:root[data-theme="dark"] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38;
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
--bg: #0f1513; --surface: #161b18; --surface-2: #1c2320; --border: #29312d;
--border-strong: #3a443e; --text: #e6ece8; --muted: #8b968f; --accent: #6cc39d;
--accent-hover: #82d0af; --accent-soft: #16291f; --danger: #f0857a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
}
:root[data-theme="light"] {
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5;
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
--bg: #f2f7f4; --surface: #ffffff; --surface-2: #eaf2ec; --border: #dde8e1;
--border-strong: #c9d6cd; --text: #17211b; --muted: #6a7c71; --accent: #3f9c72;
--accent-hover: #34825f; --accent-soft: #e6f3ec; --danger: #c0392b; --danger-soft: #fbeceb;
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
}