Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50fc0e3353 | ||
|
|
a9ea0432d8 | ||
|
|
e8c18adafb | ||
|
|
14254b483e |
@@ -45,6 +45,7 @@ from .routers import (
|
|||||||
rates,
|
rates,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
|
tags,
|
||||||
transactions,
|
transactions,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
@@ -214,6 +215,7 @@ api.include_router(settings_router.router)
|
|||||||
api.include_router(policy.router)
|
api.include_router(policy.router)
|
||||||
api.include_router(accounts.router)
|
api.include_router(accounts.router)
|
||||||
api.include_router(categories.router)
|
api.include_router(categories.router)
|
||||||
|
api.include_router(tags.router)
|
||||||
api.include_router(transactions.router)
|
api.include_router(transactions.router)
|
||||||
api.include_router(budgets.router)
|
api.include_router(budgets.router)
|
||||||
api.include_router(dashboard.router)
|
api.include_router(dashboard.router)
|
||||||
|
|||||||
@@ -1281,3 +1281,61 @@ class BankAccountLink(Base):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {}
|
return {}
|
||||||
return daten if isinstance(daten, dict) else {}
|
return daten if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
"""Ein frei vergebenes Etikett fuer Buchungen, mit eigener Farbe.
|
||||||
|
|
||||||
|
Neben der Kategorie, nicht statt ihr: Die Kategorie beantwortet „wofuer war
|
||||||
|
das Geld", ein Tag alles andere, was sich quer dazu legt – „Urlaub Island",
|
||||||
|
„steuerlich absetzbar", „zurueckfordern". Deshalb mehrere je Buchung und
|
||||||
|
keine Pflicht, genau eines zu waehlen.
|
||||||
|
|
||||||
|
Die Farbe traegt der Tag selbst, wie bei Kategorie und Gruppe: In einer
|
||||||
|
langen Liste erkennt man sie am Fleck, bevor man den Text liest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(9), nullable=False, default="#6b7480")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionTag(Base):
|
||||||
|
"""Welcher Tag haengt an welcher Buchung.
|
||||||
|
|
||||||
|
Eine eigene Tabelle und kein ``secondary``-Verweis, wie ueberall hier
|
||||||
|
(``TransferLink``, ``LinkIgnored``): Sie traegt ihre eigene ``owner_id`` und
|
||||||
|
ist damit vom Backup und von der Mandanten-Pruefung erfasst wie jede andere.
|
||||||
|
|
||||||
|
CASCADE auf beiden Seiten. Verschwindet die Buchung oder der Tag, ist die
|
||||||
|
Zuordnung gegenstandslos – es verschwindet immer nur das Etikett, nie eine
|
||||||
|
Buchung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "transaction_tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
owner_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
transaction_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Tags: frei vergebene Etiketten fuer Buchungen. Alles owner-gescoped.
|
||||||
|
|
||||||
|
Neben der Kategorie, nicht statt ihr. Die Kategorie sagt, wofuer das Geld war;
|
||||||
|
ein Tag alles, was quer dazu liegt – und davon duerfen mehrere an einer Buchung
|
||||||
|
haengen.
|
||||||
|
|
||||||
|
Die Zuordnung selbst wird hier NICHT gesetzt, sondern an der Buchung
|
||||||
|
(``PATCH /transactions/{id}`` mit ``tag_ids``). Sonst gaebe es zwei Wege, eine
|
||||||
|
Buchung zu aendern, und zwei Stellen, an denen man es vergessen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, get_owned
|
||||||
|
from ..models import Tag, User
|
||||||
|
from ..schemas import TagCreate, TagOut, TagUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
def _name_frei(db: Session, user: User, name: str, ausser_id: int | None = None) -> None:
|
||||||
|
"""Denselben Namen zweimal gaebe es in der Liste nicht zu unterscheiden."""
|
||||||
|
q = db.query(Tag.id).filter(Tag.owner_id == user.id, Tag.name == name)
|
||||||
|
if ausser_id is not None:
|
||||||
|
q = q.filter(Tag.id != ausser_id)
|
||||||
|
if q.first() is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Den Tag „{name}“ gibt es schon.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TagOut])
|
||||||
|
def list_tags(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
# Alphabetisch: In einer Auswahlliste sucht man nach dem Namen, nicht
|
||||||
|
# danach, wann der Tag entstanden ist.
|
||||||
|
return (
|
||||||
|
db.query(Tag).filter(Tag.owner_id == user.id).order_by(Tag.name).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TagOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_tag(payload: TagCreate, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
name = payload.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Leerer Name")
|
||||||
|
_name_frei(db, user, name)
|
||||||
|
tag = Tag(owner_id=user.id, name=name, color=payload.color)
|
||||||
|
db.add(tag)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tag)
|
||||||
|
return TagOut.model_validate(tag)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tag_id}", response_model=TagOut)
|
||||||
|
def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
tag = get_owned(db, Tag, tag_id, user)
|
||||||
|
daten = payload.model_dump(exclude_unset=True)
|
||||||
|
if "name" in daten and daten["name"] is not None:
|
||||||
|
name = daten["name"].strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Leerer Name")
|
||||||
|
_name_frei(db, user, name, ausser_id=tag.id)
|
||||||
|
tag.name = name
|
||||||
|
if daten.get("color") is not None:
|
||||||
|
tag.color = daten["color"]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(tag)
|
||||||
|
return TagOut.model_validate(tag)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_tag(tag_id: int, db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user)):
|
||||||
|
"""Den Tag loeschen. Die Buchungen bleiben – es faellt nur das Etikett ab
|
||||||
|
(ON DELETE CASCADE auf ``transaction_tags``)."""
|
||||||
|
tag = get_owned(db, Tag, tag_id, user)
|
||||||
|
db.delete(tag)
|
||||||
|
db.commit()
|
||||||
@@ -19,7 +19,9 @@ from ..models import (
|
|||||||
Category,
|
Category,
|
||||||
InboxItem,
|
InboxItem,
|
||||||
InboxStatus,
|
InboxStatus,
|
||||||
|
Tag,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionTag,
|
||||||
TransferLink,
|
TransferLink,
|
||||||
TxnType,
|
TxnType,
|
||||||
User,
|
User,
|
||||||
@@ -82,7 +84,8 @@ def _pruefe_zeitraum(db: Session, user: User, label: str | None, tag: date) -> s
|
|||||||
|
|
||||||
|
|
||||||
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
|
def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, str],
|
||||||
herkunft: dict[int, str] | None = None, start_tag: int = 1) -> TransactionOut:
|
herkunft: dict[int, str] | None = None, start_tag: int = 1,
|
||||||
|
tags: dict[int, list[int]] | None = None) -> TransactionOut:
|
||||||
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
acc_ccy = ccy_by_account.get(txn.account_id, "EUR")
|
||||||
if base == "EUR":
|
if base == "EUR":
|
||||||
amount_base = txn.amount_eur_cents
|
amount_base = txn.amount_eur_cents
|
||||||
@@ -113,9 +116,50 @@ def to_out(db: Session, txn: Transaction, base: str, ccy_by_account: dict[int, s
|
|||||||
payee=txn.payee,
|
payee=txn.payee,
|
||||||
description=txn.description,
|
description=txn.description,
|
||||||
notes=txn.notes,
|
notes=txn.notes,
|
||||||
|
tag_ids=(tags or {}).get(txn.id, []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tags(db: Session, user: User, txns: list[Transaction]) -> dict[int, list[int]]:
|
||||||
|
"""Die Tags je Buchung – als EINE Abfrage, nicht je Zeile.
|
||||||
|
|
||||||
|
Die Buchungsliste geht bis 5000 Zeilen; je Zeile zu fragen waere genau das
|
||||||
|
N+1, das die vier Nachbarn hier schon vermeiden.
|
||||||
|
"""
|
||||||
|
ids = [t.id for t in txns]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
nach: dict[int, list[int]] = {}
|
||||||
|
for txn_id, tag_id in db.query(
|
||||||
|
TransactionTag.transaction_id, TransactionTag.tag_id
|
||||||
|
).filter(TransactionTag.owner_id == user.id,
|
||||||
|
TransactionTag.transaction_id.in_(ids)):
|
||||||
|
nach.setdefault(txn_id, []).append(tag_id)
|
||||||
|
return nach
|
||||||
|
|
||||||
|
|
||||||
|
def _setze_tags(db: Session, user: User, txn: Transaction, tag_ids: list[int]) -> None:
|
||||||
|
"""Die Tags einer Buchung auf genau diese Liste bringen.
|
||||||
|
|
||||||
|
Ersetzen statt ergaenzen: Die Oberflaeche schickt immer den vollen Stand,
|
||||||
|
sonst liesse sich ein Tag nie wieder abnehmen. Jede ID wird geprueft – ein
|
||||||
|
fremder Tag darf auch dann nicht haengenbleiben, wenn jemand die ID raet.
|
||||||
|
"""
|
||||||
|
gewuenscht = {int(i) for i in tag_ids}
|
||||||
|
for tag_id in gewuenscht:
|
||||||
|
get_owned(db, Tag, tag_id, user)
|
||||||
|
vorhanden = {
|
||||||
|
z.tag_id: z for z in db.query(TransactionTag).filter(
|
||||||
|
TransactionTag.owner_id == user.id,
|
||||||
|
TransactionTag.transaction_id == txn.id)
|
||||||
|
}
|
||||||
|
for tag_id, zeile in vorhanden.items():
|
||||||
|
if tag_id not in gewuenscht:
|
||||||
|
db.delete(zeile)
|
||||||
|
for tag_id in gewuenscht - set(vorhanden):
|
||||||
|
db.add(TransactionTag(owner_id=user.id, transaction_id=txn.id, tag_id=tag_id))
|
||||||
|
|
||||||
|
|
||||||
def _currency_map(db: Session, user: User) -> dict[int, str]:
|
def _currency_map(db: Session, user: User) -> dict[int, str]:
|
||||||
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
||||||
return {row[0]: row[1] for row in rows}
|
return {row[0]: row[1] for row in rows}
|
||||||
@@ -184,7 +228,8 @@ def list_transactions(
|
|||||||
ccy = _currency_map(db, user)
|
ccy = _currency_map(db, user)
|
||||||
woher = _herkunft(db, user, rows)
|
woher = _herkunft(db, user, rows)
|
||||||
tag = budget.start_tag(db, user)
|
tag = budget.start_tag(db, user)
|
||||||
return [to_out(db, t, base, ccy, woher, tag) for t in rows]
|
etiketten = _tags(db, user, rows)
|
||||||
|
return [to_out(db, t, base, ccy, woher, tag, etiketten) for t in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dubletten")
|
@router.get("/dubletten")
|
||||||
@@ -332,10 +377,14 @@ def create_transaction(
|
|||||||
)
|
)
|
||||||
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||||||
db.add(txn)
|
db.add(txn)
|
||||||
|
if payload.tag_ids:
|
||||||
|
db.flush() # erst mit id laesst sich das Etikett haengen
|
||||||
|
_setze_tags(db, user, txn, payload.tag_ids)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(txn)
|
db.refresh(txn)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{txn_id}", response_model=TransactionOut)
|
@router.get("/{txn_id}", response_model=TransactionOut)
|
||||||
@@ -344,7 +393,8 @@ def get_transaction(
|
|||||||
):
|
):
|
||||||
txn = get_owned(db, Transaction, txn_id, user)
|
txn = get_owned(db, Transaction, txn_id, user)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{txn_id}", response_model=TransactionOut)
|
@router.patch("/{txn_id}", response_model=TransactionOut)
|
||||||
@@ -390,11 +440,16 @@ def update_transaction(
|
|||||||
else:
|
else:
|
||||||
txn.to_account_id = None
|
txn.to_account_id = None
|
||||||
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||||||
|
# Nach exclude_unset: mitgeschickt heisst ersetzen (auch mit leerer Liste),
|
||||||
|
# weggelassen heisst unberuehrt lassen.
|
||||||
|
if data.get("tag_ids") is not None:
|
||||||
|
_setze_tags(db, user, txn, data["tag_ids"])
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(txn)
|
db.refresh(txn)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
class TransactionTransferIn(BaseModel):
|
class TransactionTransferIn(BaseModel):
|
||||||
@@ -426,7 +481,8 @@ def make_transfer(txn_id: int, payload: TransactionTransferIn,
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(txn)
|
db.refresh(txn)
|
||||||
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
return to_out(db, txn, base_currency(db, user), _currency_map(db, user),
|
||||||
_herkunft(db, user, [txn]), budget.start_tag(db, user))
|
_herkunft(db, user, [txn]), budget.start_tag(db, user),
|
||||||
|
_tags(db, user, [txn]))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{txn_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|||||||
@@ -346,6 +346,28 @@ class SubscriptionListOut(BaseModel):
|
|||||||
summary: SubscriptionSummary
|
summary: SubscriptionSummary
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tags ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TagOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
|
||||||
|
|
||||||
|
class TagCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=60)
|
||||||
|
# Ohne Angabe dasselbe Grau wie bei einer neuen Kategorie.
|
||||||
|
color: str = "#6b7480"
|
||||||
|
|
||||||
|
|
||||||
|
class TagUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=60)
|
||||||
|
color: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Kategorien ------------------------------------------------------------
|
# --- Kategorien ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -433,6 +455,9 @@ class TransactionCreate(BaseModel):
|
|||||||
payee: str | None = Field(default=None, max_length=255)
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
description: str | None = Field(default=None, max_length=255)
|
description: str | None = Field(default=None, max_length=255)
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
# Beim Anlegen gleich mitgeben, sonst muesste man die Buchung direkt
|
||||||
|
# danach noch einmal oeffnen, nur um sie zu etikettieren.
|
||||||
|
tag_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class TransactionUpdate(BaseModel):
|
class TransactionUpdate(BaseModel):
|
||||||
@@ -452,6 +477,10 @@ class TransactionUpdate(BaseModel):
|
|||||||
payee: str | None = Field(default=None, max_length=255)
|
payee: str | None = Field(default=None, max_length=255)
|
||||||
description: str | None = Field(default=None, max_length=255)
|
description: str | None = Field(default=None, max_length=255)
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
# Die Tags dieser Buchung, vollstaendig. Mitgeschickt heisst ERSETZEN –
|
||||||
|
# eine leere Liste nimmt alle weg. Weggelassen laesst sie unberuehrt;
|
||||||
|
# darum haengt hier ``exclude_unset`` im Router dran.
|
||||||
|
tag_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
class TransactionOut(BaseModel):
|
class TransactionOut(BaseModel):
|
||||||
@@ -486,6 +515,11 @@ class TransactionOut(BaseModel):
|
|||||||
payee: str | None
|
payee: str | None
|
||||||
description: str | None
|
description: str | None
|
||||||
notes: str | None
|
notes: str | None
|
||||||
|
# Die Tags dieser Buchung. Anders als die Uebertrags-Verknuepfung stehen
|
||||||
|
# sie IM Schema und nicht in einem eigenen Endpunkt: Jede Zeile der
|
||||||
|
# Buchungsliste zeigt und filtert nach ihnen, ein zweiter Abruf wuerde im
|
||||||
|
# Browser doch nur wieder zusammengefuegt.
|
||||||
|
tag_ids: list[int] = []
|
||||||
|
|
||||||
|
|
||||||
class RateStatus(BaseModel):
|
class RateStatus(BaseModel):
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ from ..models import (
|
|||||||
LinkIgnored,
|
LinkIgnored,
|
||||||
PlannedIncomeLine,
|
PlannedIncomeLine,
|
||||||
RecoveryCode,
|
RecoveryCode,
|
||||||
|
Tag,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
TransactionTag,
|
||||||
TransferLink,
|
TransferLink,
|
||||||
User,
|
User,
|
||||||
UserSetting,
|
UserSetting,
|
||||||
@@ -135,8 +137,14 @@ USER_TABLES: list[TableSpec] = [
|
|||||||
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
||||||
label="Budget-Monatswerte"),
|
label="Budget-Monatswerte"),
|
||||||
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
|
TableSpec("dashboard_layouts", DashboardLayout, "owner", label="Dashboard-Layout"),
|
||||||
|
# target_account_id gehört dazu: Ein Transfer-Schlagwort mit festem
|
||||||
|
# Zielkonto trug sonst die ALTE Konto-Nummer in die neue Datenbank, und die
|
||||||
|
# Wiederherstellung brach mit einem Fremdschlüssel-Fehler ab – nicht nur
|
||||||
|
# diese Zeile, die ganze Sicherung liess sich nicht mehr einspielen.
|
||||||
|
# accounts steht weiter oben, die Übersetzung greift also.
|
||||||
TableSpec("categorization_rules", CategorizationRule, "owner",
|
TableSpec("categorization_rules", CategorizationRule, "owner",
|
||||||
{"category_id": "categories"}, label="Schlagwortregeln"),
|
{"category_id": "categories", "target_account_id": "accounts"},
|
||||||
|
label="Schlagwortregeln"),
|
||||||
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
||||||
label="Persönliche Einstellungen"),
|
label="Persönliche Einstellungen"),
|
||||||
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
|
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
|
||||||
@@ -154,6 +162,12 @@ USER_TABLES: list[TableSpec] = [
|
|||||||
TableSpec("link_ignored", LinkIgnored, "owner",
|
TableSpec("link_ignored", LinkIgnored, "owner",
|
||||||
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
|
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
|
||||||
label="Abgelehnte Übertrags-Vorschläge"),
|
label="Abgelehnte Übertrags-Vorschläge"),
|
||||||
|
# Erst die Tags, dann die Zuordnung – ein refs-Verweis darf nur auf eine
|
||||||
|
# schon eingefügte Tabelle zeigen.
|
||||||
|
TableSpec("tags", Tag, "owner", label="Tags"),
|
||||||
|
TableSpec("transaction_tags", TransactionTag, "owner",
|
||||||
|
{"transaction_id": "transactions", "tag_id": "tags"},
|
||||||
|
label="Tag-Zuordnungen"),
|
||||||
# Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem
|
# Ohne die Geräteschlüssel hört der Kurzbefehl auf dem Telefon nach einem
|
||||||
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
|
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
|
||||||
# der Hash – der Klartext existiert nirgends.
|
# der Hash – der Klartext existiert nirgends.
|
||||||
@@ -412,6 +426,10 @@ _MERGE_KEYS: dict[str, Callable[[dict], tuple | None]] = {
|
|||||||
# Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit
|
# Vertrags ein zweites Mal an – und scheitert an seinem Schluessel, womit
|
||||||
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
|
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
|
||||||
"subscription_logos": lambda r: ("logo", r.get("subscription_id")),
|
"subscription_logos": lambda r: ("logo", r.get("subscription_id")),
|
||||||
|
# Ohne die beiden Einträge scheitert ein zweiter Ergänzen-Lauf an
|
||||||
|
# (owner_id, name) bzw. (transaction_id, tag_id).
|
||||||
|
"tags": lambda r: ("name", (r.get("name") or "").strip().lower()),
|
||||||
|
"transaction_tags": lambda r: ("zuordnung", r.get("transaction_id"), r.get("tag_id")),
|
||||||
"transactions": lambda r: (
|
"transactions": lambda r: (
|
||||||
("uid", r["source_uid"]) if r.get("source_uid")
|
("uid", r["source_uid"]) if r.get("source_uid")
|
||||||
else ("hash", r["import_hash"]) if r.get("import_hash")
|
else ("hash", r["import_hash"]) if r.get("import_hash")
|
||||||
|
|||||||
@@ -173,6 +173,20 @@ def _pruefe_gegenseite(db: Session, user: User, konto: Account, ziel: Account,
|
|||||||
datum, exakt)
|
datum, exakt)
|
||||||
if schon is None:
|
if schon is None:
|
||||||
return
|
return
|
||||||
|
# Haengt die gefundene Buchung schon an einer anderen, ist sie NICHT die
|
||||||
|
# Gegenseite dieser hier. Der Rat „verbinden" ginge dann ins Leere: Die
|
||||||
|
# Vorschlagsliste laesst vergebene Buchungen aus, es kaeme nie ein
|
||||||
|
# Vorschlag – und der Nutzer sucht etwas, das es nicht gibt.
|
||||||
|
if _schon_verknuepft(db, user, schon):
|
||||||
|
raise UebertragFehler(
|
||||||
|
f"Auf '{ziel.name}' steht zwar eine passende Buchung vom "
|
||||||
|
f"{schon.date.strftime('%d.%m.%Y')}, die ist aber schon mit einer "
|
||||||
|
f"anderen Buchung verbunden – also nicht die Gegenseite dieser "
|
||||||
|
f"hier. Verbinden geht deshalb nicht. Sieh auf '{ziel.name}' nach, "
|
||||||
|
f"ob die vorhandene Verknüpfung stimmt, oder ob der zweite Zugang "
|
||||||
|
f"dort schlicht fehlt.",
|
||||||
|
nachfragen=True,
|
||||||
|
)
|
||||||
raise UebertragFehler(
|
raise UebertragFehler(
|
||||||
f"Auf '{ziel.name}' steht schon eine passende Buchung vom "
|
f"Auf '{ziel.name}' steht schon eine passende Buchung vom "
|
||||||
f"{schon.date.strftime('%d.%m.%Y')}. Beide Seiten sind also bereits "
|
f"{schon.date.strftime('%d.%m.%Y')}. Beide Seiten sind also bereits "
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tags fuer Buchungen.
|
||||||
|
|
||||||
|
Die Kategorie beantwortet „wofuer war das Geld". Alles, was quer dazu liegt,
|
||||||
|
hatte bisher keinen Platz: „Urlaub Island", „steuerlich absetzbar",
|
||||||
|
„zurueckfordern". Dafuer gibt es jetzt frei vergebene Etiketten mit eigener
|
||||||
|
Farbe, mehrere je Buchung.
|
||||||
|
|
||||||
|
Zwei neue Tabellen, keine bestehende wird angefasst – ``create_table`` ist
|
||||||
|
deshalb unbedenklich. Die Warnung gegen ``batch_alter_table`` aus 0034-0037
|
||||||
|
betrifft den UMBAU vorhandener Tabellen, der ueber ON DELETE CASCADE haengende
|
||||||
|
Zeilen mitrisse (tests/test_migrations.py).
|
||||||
|
|
||||||
|
Revision ID: 0038
|
||||||
|
Revises: 0037
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0038"
|
||||||
|
down_revision = "0037"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tags",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("color", sa.String(length=9), nullable=False,
|
||||||
|
server_default="#6b7480"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("owner_id", "name", name="uq_tag_owner_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tags_owner_id", "tags", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"transaction_tags",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("transaction_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("transactions.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("tag_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tags.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("transaction_id", "tag_id", name="uq_txn_tag"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_transaction_tags_owner_id", "transaction_tags", ["owner_id"])
|
||||||
|
op.create_index("ix_transaction_tags_transaction_id", "transaction_tags",
|
||||||
|
["transaction_id"])
|
||||||
|
op.create_index("ix_transaction_tags_tag_id", "transaction_tags", ["tag_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("transaction_tags")
|
||||||
|
op.drop_table("tags")
|
||||||
@@ -87,3 +87,44 @@ def test_jede_nutzertabelle_ist_im_backup_beruecksichtigt():
|
|||||||
f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: "
|
f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: "
|
||||||
f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen."
|
f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_transfer_schlagwort_behaelt_sein_zielkonto(admin):
|
||||||
|
"""Ein Transfer-Schlagwort mit festem Zielkonto trug beim Wiedereinspielen
|
||||||
|
die ALTE Konto-Nummer in die neue Datenbank. Beim Einspielen brach dann die
|
||||||
|
ganze Sicherung mit einem Fremdschluessel-Fehler ab - nicht nur diese eine
|
||||||
|
Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung also gar
|
||||||
|
nicht mehr heran."""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def _konto(name):
|
||||||
|
return admin.post("/api/accounts", json={
|
||||||
|
"name": name, "type": "sparkonto", "currency": "CHF",
|
||||||
|
"opening_balance_cents": 0}).json()
|
||||||
|
|
||||||
|
# Erst Luecken in die Konto-Nummern reissen, dann das Zielkonto anlegen.
|
||||||
|
# Ohne Luecken vergibt SQLite beim Einspielen zufaellig dieselben Nummern
|
||||||
|
# und der Fehler bliebe unsichtbar - genau deshalb ist er so lange
|
||||||
|
# unbemerkt geblieben.
|
||||||
|
weg = [_konto(f"Weg {secrets.token_hex(3)}") for _ in range(5)]
|
||||||
|
ziel = _konto(f"Sparen {secrets.token_hex(3)}")
|
||||||
|
for k in weg:
|
||||||
|
assert admin.delete(f"/api/accounts/{k['id']}").status_code in (200, 204)
|
||||||
|
|
||||||
|
wort = f"um chf sparen {secrets.token_hex(5)}"
|
||||||
|
r = admin.post("/api/categories/transfer-keywords",
|
||||||
|
json={"word": wort, "target_account_id": ziel["id"]})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
|
||||||
|
daten = json.loads(admin.get("/api/export/backup.json").content)
|
||||||
|
r = admin.post("/api/backup/restore",
|
||||||
|
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
|
||||||
|
"application/json")},
|
||||||
|
data={"mode": "replace"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
konten = {a["name"]: a["id"] for a in admin.get("/api/accounts").json()}
|
||||||
|
regel = next(w for w in admin.get("/api/categories/transfer-keywords").json()
|
||||||
|
if w["word"] == wort)
|
||||||
|
assert regel["target_account_id"] == konten[ziel["name"]], \
|
||||||
|
"das Zielkonto muss auf die NEUE Nummer zeigen"
|
||||||
|
|||||||
@@ -455,3 +455,61 @@ def test_verknuepfte_buchungen_landen_nicht_in_uebersprungen(admin):
|
|||||||
# Und sie bleibt unangetastet: kein Uebertrag, kein verschobenes Geld.
|
# Und sie bleibt unangetastet: kein Uebertrag, kein verschobenes Geld.
|
||||||
assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense"
|
assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense"
|
||||||
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == betrag
|
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == betrag
|
||||||
|
|
||||||
|
|
||||||
|
def _tx(admin, konto, typ, datum, cents, payee):
|
||||||
|
return admin.post("/api/transactions", json={
|
||||||
|
"type": typ, "date": datum, "account_id": konto["id"],
|
||||||
|
"amount_cents": cents, "payee": payee}).json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vergebene_gegenseite_wird_als_solche_benannt(admin):
|
||||||
|
"""Die Pruefung fragt „gibt es auf dem Zielkonto eine passende Buchung?",
|
||||||
|
nicht „ist sie noch frei?". Haengt die gefundene schon an einer anderen
|
||||||
|
Buchung, ist sie NICHT die Gegenseite dieser hier – und der Rat „verbinden"
|
||||||
|
geht ins Leere, denn die Vorschlagsliste laesst vergebene Buchungen aus.
|
||||||
|
Dann muss die Meldung sagen, was wirklich los ist."""
|
||||||
|
quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}")
|
||||||
|
pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}")
|
||||||
|
anderes = _kto(admin, name=f"Anderes {secrets.token_hex(3)}")
|
||||||
|
kennung = f"um chf sparen {secrets.token_hex(6)}"
|
||||||
|
betrag = 62 + secrets.randbelow(900)
|
||||||
|
|
||||||
|
# Der Zugang im Pocket gehoert schon zu einer Buchung vom dritten Konto.
|
||||||
|
zugang = _tx(admin, pocket, "income", "2026-05-20", betrag, "Pocket")
|
||||||
|
fremd = _tx(admin, anderes, "expense", "2026-05-20", betrag, "Fremd")
|
||||||
|
assert admin.post("/api/uebertraege/verbinden",
|
||||||
|
json={"a_id": fremd["id"], "b_id": zugang["id"]}).status_code == 200
|
||||||
|
|
||||||
|
# Und jetzt kommt die Revolut-Ausgabe, gleicher Tag, gleicher Betrag.
|
||||||
|
raus = _tx(admin, quelle, "expense", "2026-05-20", betrag, kennung.upper())
|
||||||
|
assert admin.post("/api/categories/transfer-keywords", json={
|
||||||
|
"word": kennung, "target_account_id": pocket["id"]}).status_code == 201
|
||||||
|
|
||||||
|
r = admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}).json()
|
||||||
|
grund = next(z["grund"] for z in r["uebersprungen"] if z["text"] == kennung.upper())
|
||||||
|
assert "schon mit einer anderen Buchung verbunden" in grund
|
||||||
|
assert "Richtig ist hier, die beiden zu verbinden" not in grund, \
|
||||||
|
"zum Verbinden raten waere hier falsch – die Gegenseite ist vergeben"
|
||||||
|
|
||||||
|
# Geblockt wird weiter: kein Uebertrag, kein verschobenes Geld.
|
||||||
|
assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense"
|
||||||
|
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == betrag
|
||||||
|
|
||||||
|
|
||||||
|
def test_freie_gegenseite_raet_weiter_zum_verbinden(admin):
|
||||||
|
"""Die Gegenprobe: Ist die Gegenseite noch frei, bleibt es beim alten Rat.
|
||||||
|
Sonst schluckte der neue Zweig alle Faelle."""
|
||||||
|
quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}")
|
||||||
|
pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}")
|
||||||
|
kennung = f"um chf sparen {secrets.token_hex(6)}"
|
||||||
|
betrag = 62 + secrets.randbelow(900)
|
||||||
|
|
||||||
|
_tx(admin, pocket, "income", "2026-05-20", betrag, "Pocket")
|
||||||
|
_tx(admin, quelle, "expense", "2026-05-20", betrag, kennung.upper())
|
||||||
|
assert admin.post("/api/categories/transfer-keywords", json={
|
||||||
|
"word": kennung, "target_account_id": pocket["id"]}).status_code == 201
|
||||||
|
|
||||||
|
r = admin.post("/api/categories/keywords/anwenden", params={"vorschau": False}).json()
|
||||||
|
grund = next(z["grund"] for z in r["uebersprungen"] if z["text"] == kennung.upper())
|
||||||
|
assert "Richtig ist hier, die beiden zu verbinden" in grund
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Tags: frei vergebene Etiketten fuer Buchungen, mit eigener Farbe.
|
||||||
|
|
||||||
|
Die Kategorie beantwortet "wofuer war das Geld". Alles, was quer dazu liegt –
|
||||||
|
"Urlaub Island", "steuerlich absetzbar", "zurueckfordern" –, passt in keine
|
||||||
|
Kategorie, weil eine Buchung davon mehrere auf einmal sein kann. Genau das
|
||||||
|
koennen Tags, und genau daran haengt hier alles: mehrere je Buchung, frei
|
||||||
|
benannt, und beim Abnehmen darf auch keiner uebrig bleiben.
|
||||||
|
|
||||||
|
Die Datenbank lebt ueber den ganzen Testlauf. Namen und Betraege deshalb
|
||||||
|
eindeutig wuerfeln, sonst stolpert man ueber die Daten anderer Tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from conftest import login_ready, new_user
|
||||||
|
|
||||||
|
|
||||||
|
def _tag(admin, name=None, color="#3f51b5"):
|
||||||
|
r = admin.post("/api/tags", json={
|
||||||
|
"name": name or f"Tag {secrets.token_hex(4)}", "color": color})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _konto(admin, name=None):
|
||||||
|
return admin.post("/api/accounts", json={
|
||||||
|
"name": name or f"Konto {secrets.token_hex(3)}", "type": "girokonto",
|
||||||
|
"currency": "CHF", "opening_balance_cents": 0}).json()
|
||||||
|
|
||||||
|
|
||||||
|
def _buchung(admin, konto, cents=None, payee="Testkauf"):
|
||||||
|
return admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
|
||||||
|
"amount_cents": cents or (100 + secrets.randbelow(9000)),
|
||||||
|
"payee": payee}).json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_anlegen_umbenennen_umfaerben_loeschen(admin):
|
||||||
|
name = f"Urlaub {secrets.token_hex(4)}"
|
||||||
|
tag = _tag(admin, name, "#2a78d6")
|
||||||
|
assert tag["name"] == name and tag["color"] == "#2a78d6"
|
||||||
|
|
||||||
|
neu = f"Reise {secrets.token_hex(4)}"
|
||||||
|
r = admin.patch(f"/api/tags/{tag['id']}", json={"name": neu, "color": "#eb6834"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["name"] == neu and r.json()["color"] == "#eb6834"
|
||||||
|
|
||||||
|
assert admin.delete(f"/api/tags/{tag['id']}").status_code == 204
|
||||||
|
assert tag["id"] not in {x["id"] for x in admin.get("/api/tags").json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_derselbe_name_zweimal_wird_abgelehnt(admin):
|
||||||
|
"""Zwei gleich heissende Tags liessen sich in der Auswahl nicht unterscheiden."""
|
||||||
|
name = f"Doppelt {secrets.token_hex(4)}"
|
||||||
|
_tag(admin, name)
|
||||||
|
r = admin.post("/api/tags", json={"name": name, "color": "#000000"})
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
|
||||||
|
# Auch beim Umbenennen auf einen vergebenen Namen.
|
||||||
|
anderer = _tag(admin)
|
||||||
|
assert admin.patch(f"/api/tags/{anderer['id']}", json={"name": name}).status_code == 409
|
||||||
|
# Auf den EIGENEN Namen umbenennen bleibt erlaubt.
|
||||||
|
assert admin.patch(f"/api/tags/{anderer['id']}",
|
||||||
|
json={"name": anderer["name"], "color": "#111111"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_leerer_name_wird_abgelehnt(admin):
|
||||||
|
assert admin.post("/api/tags", json={"name": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_buchung_traegt_mehrere_tags(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
a, b = _tag(admin), _tag(admin)
|
||||||
|
txn = _buchung(admin, konto)
|
||||||
|
assert txn["tag_ids"] == []
|
||||||
|
|
||||||
|
r = admin.patch(f"/api/transactions/{txn['id']}",
|
||||||
|
json={"tag_ids": [a["id"], b["id"]]})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert sorted(r.json()["tag_ids"]) == sorted([a["id"], b["id"]])
|
||||||
|
|
||||||
|
# Auch beim Einzelabruf und in der Liste.
|
||||||
|
assert sorted(admin.get(f"/api/transactions/{txn['id']}").json()["tag_ids"]) \
|
||||||
|
== sorted([a["id"], b["id"]])
|
||||||
|
aus_liste = next(t for t in admin.get("/api/transactions").json() if t["id"] == txn["id"])
|
||||||
|
assert sorted(aus_liste["tag_ids"]) == sorted([a["id"], b["id"]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mitgeschickt_heisst_ersetzen_weggelassen_heisst_unberuehrt(admin):
|
||||||
|
"""Die Oberflaeche schickt immer den vollen Stand. Eine leere Liste muss
|
||||||
|
deshalb alle abnehmen - sonst wuerde man einen Tag nie wieder los. Wer
|
||||||
|
``tag_ids`` gar nicht mitschickt (etwa beim Aendern des Betrags), darf die
|
||||||
|
Tags aber nicht verlieren."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
a, b = _tag(admin), _tag(admin)
|
||||||
|
txn = _buchung(admin, konto)
|
||||||
|
|
||||||
|
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [a["id"], b["id"]]})
|
||||||
|
|
||||||
|
# Ersetzen durch genau einen.
|
||||||
|
r = admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [b["id"]]})
|
||||||
|
assert r.json()["tag_ids"] == [b["id"]]
|
||||||
|
|
||||||
|
# Etwas anderes aendern - die Tags bleiben.
|
||||||
|
r = admin.patch(f"/api/transactions/{txn['id']}", json={"amount_cents": 4242})
|
||||||
|
assert r.json()["tag_ids"] == [b["id"]], "ohne tag_ids darf sich nichts aendern"
|
||||||
|
|
||||||
|
# Leere Liste nimmt alle ab.
|
||||||
|
r = admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": []})
|
||||||
|
assert r.json()["tag_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_loeschen_laesst_die_buchung_stehen(admin):
|
||||||
|
"""Es verschwindet das Etikett, nie eine Buchung."""
|
||||||
|
konto = _konto(admin)
|
||||||
|
tag = _tag(admin)
|
||||||
|
txn = _buchung(admin, konto, cents=777)
|
||||||
|
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
|
||||||
|
|
||||||
|
assert admin.delete(f"/api/tags/{tag['id']}").status_code == 204
|
||||||
|
|
||||||
|
danach = admin.get(f"/api/transactions/{txn['id']}").json()
|
||||||
|
assert danach["amount_cents"] == 777
|
||||||
|
assert danach["tag_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_buchung_loeschen_laesst_den_tag_stehen(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
tag = _tag(admin)
|
||||||
|
txn = _buchung(admin, konto)
|
||||||
|
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
|
||||||
|
|
||||||
|
assert admin.delete(f"/api/transactions/{txn['id']}").status_code == 204
|
||||||
|
assert tag["id"] in {x["id"] for x in admin.get("/api/tags").json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fremder_tag_bleibt_unerreichbar(admin):
|
||||||
|
"""Mandanten-Trennung: 404, nicht 403 - wie ueberall im Projekt."""
|
||||||
|
fremd = _tag(admin)
|
||||||
|
anderer, _ = login_ready(*new_user(admin))
|
||||||
|
|
||||||
|
assert fremd["id"] not in {x["id"] for x in anderer.get("/api/tags").json()}
|
||||||
|
assert anderer.patch(f"/api/tags/{fremd['id']}", json={"name": "geklaut"}).status_code == 404
|
||||||
|
assert anderer.delete(f"/api/tags/{fremd['id']}").status_code == 404
|
||||||
|
|
||||||
|
# Und er laesst sich auch nicht an die eigene Buchung haengen.
|
||||||
|
konto = _konto(anderer)
|
||||||
|
txn = _buchung(anderer, konto)
|
||||||
|
assert anderer.patch(f"/api/transactions/{txn['id']}",
|
||||||
|
json={"tag_ids": [fremd["id"]]}).status_code == 404
|
||||||
|
assert anderer.get(f"/api/transactions/{txn['id']}").json()["tag_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_tags_ueberleben_das_backup(admin):
|
||||||
|
konto = _konto(admin)
|
||||||
|
name = f"Backup {secrets.token_hex(4)}"
|
||||||
|
tag = _tag(admin, name, "#1baf7a")
|
||||||
|
# Der Text muss eindeutig sein, nicht der Betrag: Nach dem Einspielen sind
|
||||||
|
# die IDs neu vergeben, und andere Tests legen laufend Buchungen an.
|
||||||
|
text = f"Tagtraeger {secrets.token_hex(4)}"
|
||||||
|
txn = _buchung(admin, konto, cents=5151, payee=text)
|
||||||
|
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
|
||||||
|
|
||||||
|
daten = json.loads(admin.get("/api/export/backup.json").content)
|
||||||
|
gesichert = [x for x in daten["data"]["tags"] if x["name"] == name]
|
||||||
|
assert gesichert and gesichert[0]["color"] == "#1baf7a"
|
||||||
|
assert daten["data"]["transaction_tags"], "die Zuordnung muss mitgesichert sein"
|
||||||
|
|
||||||
|
admin.delete(f"/api/tags/{tag['id']}")
|
||||||
|
r = admin.post("/api/backup/restore",
|
||||||
|
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
|
||||||
|
"application/json")},
|
||||||
|
data={"mode": "replace"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
zurueck = [x for x in admin.get("/api/tags").json() if x["name"] == name]
|
||||||
|
assert zurueck and zurueck[0]["color"] == "#1baf7a"
|
||||||
|
wieder = [t for t in admin.get("/api/transactions").json() if t["payee"] == text]
|
||||||
|
assert wieder, "die Buchung muss nach dem Einspielen wieder da sein"
|
||||||
|
assert wieder[0]["tag_ids"] == [zurueck[0]["id"]], \
|
||||||
|
"und ihr Tag muss wieder an ihr haengen"
|
||||||
@@ -10,6 +10,7 @@ import ChangePassword from "./pages/ChangePassword";
|
|||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Accounts from "./pages/Accounts";
|
import Accounts from "./pages/Accounts";
|
||||||
import Categories from "./pages/Categories";
|
import Categories from "./pages/Categories";
|
||||||
|
import Tags from "./pages/Tags";
|
||||||
import Transactions from "./pages/Transactions";
|
import Transactions from "./pages/Transactions";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
import Abos from "./pages/Abos";
|
import Abos from "./pages/Abos";
|
||||||
@@ -51,6 +52,7 @@ function Sidebar() {
|
|||||||
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
|
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
|
||||||
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||||||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||||
|
<NavItem to="/tags" icon="tags" label={t("nav.tags")} />
|
||||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||||
<NavItem to="/eingang" icon="inbox" label={t("nav.todo")} />
|
<NavItem to="/eingang" icon="inbox" label={t("nav.todo")} />
|
||||||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||||
@@ -142,6 +144,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
|
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
|
||||||
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
<Route path="/konten" element={<Protected><Accounts /></Protected>} />
|
||||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||||
|
<Route path="/tags" element={<Protected><Tags /></Protected>} />
|
||||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||||
{/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen,
|
{/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen,
|
||||||
stand jede Bank-Buchung ohne Kategorie in beiden Listen. */}
|
stand jede Bank-Buchung ohne Kategorie in beiden Listen. */}
|
||||||
|
|||||||
@@ -338,6 +338,13 @@ export const api = {
|
|||||||
updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }),
|
updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }),
|
||||||
deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }),
|
deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Tags. Gesetzt werden sie an der Buchung (updateTransaction mit tag_ids),
|
||||||
|
// hier geht es nur um die Tags selbst.
|
||||||
|
listTags: () => request("/tags"),
|
||||||
|
createTag: (body) => request("/tags", { method: "POST", body }),
|
||||||
|
updateTag: (id, body) => request(`/tags/${id}`, { method: "PATCH", body }),
|
||||||
|
deleteTag: (id) => request(`/tags/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Wechselkurse
|
// Wechselkurse
|
||||||
rateStatus: () => request("/rates/status"),
|
rateStatus: () => request("/rates/status"),
|
||||||
convertRate: (amountCents, from, to, on) => {
|
convertRate: (amountCents, from, to, on) => {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Farbiges Text-Plättchen für einen Tag.
|
||||||
|
//
|
||||||
|
// Nicht `.badge`: das kennt nur feste Varianten (accent/danger/warn/ok), ein
|
||||||
|
// Tag trägt aber eine frei gewählte Farbe. Der getönte Hintergrund stammt aus
|
||||||
|
// demselben Kniff wie beim IconChip – `${color}22` ist dieselbe Farbe mit 13 %
|
||||||
|
// Deckkraft. Dadurch bleibt der Text in Hell UND Dunkel lesbar, ohne dass für
|
||||||
|
// jede Farbe ein zweiter Wert gepflegt werden müsste.
|
||||||
|
export default function TagChip({ name, color = "#6b7480", onRemove, removeTitle }) {
|
||||||
|
return (
|
||||||
|
<span className="tag-chip" style={{ color, background: `${color}22` }}>
|
||||||
|
{name}
|
||||||
|
{onRemove && (
|
||||||
|
<button type="button" title={removeTitle} onClick={onRemove} aria-label={removeTitle}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import Icon from "./Icon";
|
||||||
|
import TagChip from "./TagChip";
|
||||||
|
|
||||||
|
// Mehrfachauswahl für Tags. Bewusst nach dem Vorbild von CategorySelect
|
||||||
|
// gebaut und mit denselben `.catsel-*`-Klassen: Das Panel wird per Portal mit
|
||||||
|
// fester Position gerendert, weil es sonst in der scrollbaren Buchungstabelle
|
||||||
|
// abgeschnitten würde. Unterschied zum Vorbild: flache Liste statt Baum, und
|
||||||
|
// ein Klick schaltet um statt zu schliessen – man vergibt selten genau einen.
|
||||||
|
export default function TagSelect({
|
||||||
|
tags = [],
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
minWidth = 0,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = null,
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const gewaehlt = useMemo(() => new Set((value || []).map(Number)), [value]);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [pos, setPos] = useState(null);
|
||||||
|
|
||||||
|
const btnRef = useRef(null);
|
||||||
|
const panelRef = useRef(null);
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
|
||||||
|
const byId = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
|
||||||
|
const sel = useMemo(
|
||||||
|
() => [...gewaehlt].map((id) => byId[id]).filter(Boolean),
|
||||||
|
[gewaehlt, byId],
|
||||||
|
);
|
||||||
|
|
||||||
|
function computePos() {
|
||||||
|
const el = btnRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const width = Math.max(r.width, 240);
|
||||||
|
const spaceBelow = window.innerHeight - r.bottom;
|
||||||
|
const spaceAbove = r.top;
|
||||||
|
const desired = 320;
|
||||||
|
let top;
|
||||||
|
let maxHeight;
|
||||||
|
if (spaceBelow >= 220 || spaceBelow >= spaceAbove) {
|
||||||
|
maxHeight = Math.min(desired, spaceBelow - 10);
|
||||||
|
top = r.bottom + 4;
|
||||||
|
} else {
|
||||||
|
maxHeight = Math.min(desired, spaceAbove - 10);
|
||||||
|
top = r.top - 4 - maxHeight;
|
||||||
|
}
|
||||||
|
let left = r.left;
|
||||||
|
if (left + width > window.innerWidth - 8) left = window.innerWidth - 8 - width;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
setPos({ left, top, width, maxHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
if (disabled) return;
|
||||||
|
computePos();
|
||||||
|
setQ("");
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
searchRef.current?.focus();
|
||||||
|
const onDown = (e) => {
|
||||||
|
if (panelRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e) => e.key === "Escape" && setOpen(false);
|
||||||
|
// Wie beim CategorySelect: Scrollen INNERHALB der Liste darf sie nicht
|
||||||
|
// schliessen, Scrollen der Seite darunter schon.
|
||||||
|
const onScrollResize = (e) => {
|
||||||
|
if (e?.target && panelRef.current?.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("scroll", onScrollResize, true);
|
||||||
|
window.addEventListener("resize", onScrollResize);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("scroll", onScrollResize, true);
|
||||||
|
window.removeEventListener("resize", onScrollResize);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function umschalten(id) {
|
||||||
|
const next = new Set(gewaehlt);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
// Offen lassen: Wer einen Tag setzt, setzt oft gleich den zweiten.
|
||||||
|
onChange([...next]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ql = q.trim().toLowerCase();
|
||||||
|
const sichtbar = ql ? tags.filter((x) => x.name.toLowerCase().includes(ql)) : tags;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="catsel" style={{ minWidth }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={btnRef}
|
||||||
|
className={`catsel-btn${open ? " open" : ""}`}
|
||||||
|
onClick={() => (open ? setOpen(false) : openPanel())}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{sel.length ? (
|
||||||
|
<span className="catsel-value tag-chips">
|
||||||
|
{sel.map((x) => <TagChip key={x.id} name={x.name} color={x.color} />)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="catsel-value muted ellipsis">
|
||||||
|
{placeholder || t("tags.choose")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Icon name="chevron-down" size={15} className="catsel-caret" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && pos && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="catsel-panel"
|
||||||
|
style={{ left: pos.left, top: pos.top, width: pos.width, maxHeight: pos.maxHeight }}
|
||||||
|
>
|
||||||
|
<div className="catsel-search">
|
||||||
|
<Icon name="search" size={14} />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t("tags.searchPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="catsel-list">
|
||||||
|
{sichtbar.map((x) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={x.id}
|
||||||
|
className={`catsel-opt${gewaehlt.has(x.id) ? " selected" : ""}`}
|
||||||
|
onClick={() => umschalten(x.id)}
|
||||||
|
>
|
||||||
|
<TagChip name={x.name} color={x.color} />
|
||||||
|
<span className="grow" />
|
||||||
|
{gewaehlt.has(x.id) && <Icon name="check" size={15} className="catsel-check" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{sichtbar.length === 0 && (
|
||||||
|
<div className="catsel-empty muted small">
|
||||||
|
{tags.length === 0 ? t("tags.empty") : t("tags.noMatch")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2036,3 +2036,46 @@ tr.row-dup {
|
|||||||
.sso-zeile:last-child { border-bottom: none; }
|
.sso-zeile:last-child { border-bottom: none; }
|
||||||
.sso-haken { color: var(--ok); flex: none; }
|
.sso-haken { color: var(--ok); flex: none; }
|
||||||
.btn.sso-verknuepfen { justify-content: center; text-decoration: none; }
|
.btn.sso-verknuepfen { justify-content: center; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ---------- Tags ---------- */
|
||||||
|
/* Farbiges Text-Plaettchen. Die Farbe kommt aus dem Datensatz und steht
|
||||||
|
deshalb inline am Element (siehe TagChip); hier nur die Form. */
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 9px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 560;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.tag-chip button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tag-chip button:hover { opacity: 1; }
|
||||||
|
/* Mehrere Plaettchen im Auswahlknopf: Sie duerfen umbrechen, sonst wuerde in
|
||||||
|
der Tabellenzelle ab dem zweiten Tag nichts mehr zu sehen sein. */
|
||||||
|
.tag-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|||||||
+18
-1
@@ -24,7 +24,8 @@
|
|||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"todo": "Zu erledigen",
|
"todo": "Zu erledigen",
|
||||||
"abos": "Abos"
|
"abos": "Abos",
|
||||||
|
"tags": "Tags"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"subtitle": "Bitte anmelden",
|
"subtitle": "Bitte anmelden",
|
||||||
@@ -242,6 +243,22 @@
|
|||||||
"autoTarget": "automatisch",
|
"autoTarget": "automatisch",
|
||||||
"empty": "Noch keine Transfer-Schlagwörter."
|
"empty": "Noch keine Transfer-Schlagwörter."
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"title": "Tags",
|
||||||
|
"intro": "Tags liegen quer zur Kategorie: Eine Buchung hat genau eine Kategorie, aber beliebig viele Tags – etwa „Urlaub Island“, „absetzbar“ oder „zurückfordern“. Gesetzt werden sie an der Buchung, in der Liste oder beim Bearbeiten.",
|
||||||
|
"new": "Neuer Tag",
|
||||||
|
"edit": "Tag bearbeiten",
|
||||||
|
"name": "Name",
|
||||||
|
"color": "Farbe",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"column": "Tags",
|
||||||
|
"none": "Keine Tags",
|
||||||
|
"choose": "Tags wählen …",
|
||||||
|
"searchPlaceholder": "Tag suchen …",
|
||||||
|
"noMatch": "Kein Tag passt dazu.",
|
||||||
|
"empty": "Noch keine Tags angelegt.",
|
||||||
|
"deleteConfirm": "Den Tag „{{name}}“ löschen? Die Buchungen bleiben stehen – sie verlieren nur dieses Etikett."
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Kategorien",
|
"title": "Kategorien",
|
||||||
"newGroup": "Neue Gruppe",
|
"newGroup": "Neue Gruppe",
|
||||||
|
|||||||
+18
-1
@@ -24,7 +24,8 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"todo": "To do",
|
"todo": "To do",
|
||||||
"abos": "Subscriptions"
|
"abos": "Subscriptions",
|
||||||
|
"tags": "Tags"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"subtitle": "Please sign in",
|
"subtitle": "Please sign in",
|
||||||
@@ -242,6 +243,22 @@
|
|||||||
"autoTarget": "automatic",
|
"autoTarget": "automatic",
|
||||||
"empty": "No transfer keywords yet."
|
"empty": "No transfer keywords yet."
|
||||||
},
|
},
|
||||||
|
"tags": {
|
||||||
|
"title": "Tags",
|
||||||
|
"intro": "Tags sit across categories: a transaction has exactly one category but any number of tags – say “Iceland trip”, “deductible” or “claim back”. You set them on the transaction, in the list or while editing.",
|
||||||
|
"new": "New tag",
|
||||||
|
"edit": "Edit tag",
|
||||||
|
"name": "Name",
|
||||||
|
"color": "Colour",
|
||||||
|
"preview": "Preview",
|
||||||
|
"column": "Tags",
|
||||||
|
"none": "No tags",
|
||||||
|
"choose": "Choose tags …",
|
||||||
|
"searchPlaceholder": "Search tag …",
|
||||||
|
"noMatch": "No tag matches.",
|
||||||
|
"empty": "No tags yet.",
|
||||||
|
"deleteConfirm": "Delete the tag “{{name}}”? The transactions stay – they just lose this label."
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
"newGroup": "New group",
|
"newGroup": "New group",
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { api } from "../api";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
import Modal from "../components/Modal";
|
||||||
|
import TagChip from "../components/TagChip";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
|
||||||
|
// Tags pflegen: anlegen, umbenennen, umfärben, löschen.
|
||||||
|
//
|
||||||
|
// Bewusst eine eigene Seite und nicht ein Abschnitt unter den Kategorien: Ein
|
||||||
|
// Tag ist etwas anderes als eine Kategorie. Die Kategorie beantwortet „wofür
|
||||||
|
// war das Geld" und gilt genau einmal; ein Tag legt sich quer darüber und darf
|
||||||
|
// mehrfach an derselben Buchung hängen.
|
||||||
|
//
|
||||||
|
// Gesetzt werden Tags nicht hier, sondern an der Buchung – in der Liste und im
|
||||||
|
// Bearbeiten-Dialog. Hier geht es nur um den Vorrat.
|
||||||
|
const STANDARDFARBE = "#3f51b5";
|
||||||
|
|
||||||
|
export default function Tags() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
const [modal, setModal] = useState(null); // { editing, form }
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
setTags(await api.listTags());
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => { laden(); }, []);
|
||||||
|
|
||||||
|
function oeffnen(editing = null) {
|
||||||
|
setModal({
|
||||||
|
editing,
|
||||||
|
form: editing
|
||||||
|
? { name: editing.name, color: editing.color }
|
||||||
|
: { name: "", color: STANDARDFARBE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const { editing, form } = modal;
|
||||||
|
const rumpf = { name: form.name.trim(), color: form.color };
|
||||||
|
try {
|
||||||
|
if (editing) await api.updateTag(editing.id, rumpf);
|
||||||
|
else await api.createTag(rumpf);
|
||||||
|
setModal(null);
|
||||||
|
laden();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loeschen(tag) {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: t("common.delete"),
|
||||||
|
message: t("tags.deleteConfirm", { name: tag.name }),
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: t("common.delete"),
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
await api.deleteTag(tag.id);
|
||||||
|
laden();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1>{t("tags.title")}</h1>
|
||||||
|
<button className="btn primary" onClick={() => oeffnen()}>
|
||||||
|
<Icon name="plus" size={16} /> {t("tags.new")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<p className="muted small" style={{ marginTop: 0 }}>{t("tags.intro")}</p>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<div className="cat-row" key={tag.id}>
|
||||||
|
<TagChip name={tag.name} color={tag.color} />
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="btn sm ghost" title={t("common.edit")} onClick={() => oeffnen(tag)}>
|
||||||
|
<Icon name="edit" size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tag)}>
|
||||||
|
<Icon name="trash" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tags.length === 0 && <div className="empty-state">{t("tags.empty")}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modal && (
|
||||||
|
<Modal
|
||||||
|
title={modal.editing ? t("tags.edit") : t("tags.new")}
|
||||||
|
onClose={() => setModal(null)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={() => setModal(null)}>{t("common.cancel")}</button>
|
||||||
|
<button className="btn primary" form="tag-form" type="submit">{t("common.save")}</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form id="tag-form" onSubmit={speichern}>
|
||||||
|
<label>
|
||||||
|
<span>{t("tags.name")}</span>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
maxLength={60}
|
||||||
|
value={modal.form.name}
|
||||||
|
onChange={(e) => setModal({ ...modal, form: { ...modal.form, name: e.target.value } })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("tags.color")}</span>
|
||||||
|
<div className="color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={modal.form.color}
|
||||||
|
onChange={(e) => setModal({ ...modal, form: { ...modal.form, color: e.target.value } })}
|
||||||
|
/>
|
||||||
|
<TagChip name={modal.form.name.trim() || t("tags.preview")} color={modal.form.color} />
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import IconChip from "../components/IconChip";
|
import IconChip from "../components/IconChip";
|
||||||
import Money from "../components/Money";
|
import Money from "../components/Money";
|
||||||
|
import TagSelect from "../components/TagSelect";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import BudgetZeitraum from "../components/BudgetZeitraum";
|
import BudgetZeitraum from "../components/BudgetZeitraum";
|
||||||
import AccountSelect from "../components/AccountSelect";
|
import AccountSelect from "../components/AccountSelect";
|
||||||
@@ -33,6 +34,7 @@ function emptyForm() {
|
|||||||
payee: "",
|
payee: "",
|
||||||
description: "",
|
description: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
tag_ids: [],
|
||||||
budget_as_expense: true,
|
budget_as_expense: true,
|
||||||
budget_category_id: "",
|
budget_category_id: "",
|
||||||
// Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es.
|
// Leer heisst: aus dem Datum. Der Normalfall, und er bleibt es.
|
||||||
@@ -56,12 +58,17 @@ export default function Transactions() {
|
|||||||
const [uebertrag, setUebertrag] = useState(null);
|
const [uebertrag, setUebertrag] = useState(null);
|
||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
const [filter, setFilter] = useState({ account_id: "", type: "" });
|
const [filter, setFilter] = useState({ account_id: "", type: "" });
|
||||||
const [form, setForm] = useState(null);
|
const [form, setForm] = useState(null);
|
||||||
const [editing, setEditing] = useState(null);
|
const [editing, setEditing] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
|
const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
|
||||||
|
const tagById = useMemo(() => Object.fromEntries(tags.map((x) => [x.id, x])), [tags]);
|
||||||
|
// Die Tags einer Zeile als Datensaetze – fuer die Anzeige und fuer den
|
||||||
|
// Spaltenfilter, der mit Namen arbeitet und nicht mit IDs.
|
||||||
|
const tagsVon = (tx) => (tx.tag_ids || []).map((id) => tagById[id]).filter(Boolean);
|
||||||
const categoryById = useMemo(() => {
|
const categoryById = useMemo(() => {
|
||||||
const m = {};
|
const m = {};
|
||||||
for (const g of groups) for (const c of g.categories) m[c.id] = c;
|
for (const g of groups) for (const c of g.categories) m[c.id] = c;
|
||||||
@@ -70,9 +77,12 @@ export default function Transactions() {
|
|||||||
|
|
||||||
async function ladenStamm() {
|
async function ladenStamm() {
|
||||||
try {
|
try {
|
||||||
const [accs, cats] = await Promise.all([api.listAccounts(), api.listCategories()]);
|
const [accs, cats, tagListe] = await Promise.all([
|
||||||
|
api.listAccounts(), api.listCategories(), api.listTags(),
|
||||||
|
]);
|
||||||
setAccounts(accs);
|
setAccounts(accs);
|
||||||
setGroups(cats);
|
setGroups(cats);
|
||||||
|
setTags(tagListe);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, "error");
|
toast(err.message, "error");
|
||||||
}
|
}
|
||||||
@@ -120,6 +130,31 @@ export default function Transactions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Je Zeile eine Warteschlange. Die Auswahl bleibt nach einem Klick offen –
|
||||||
|
// man vergibt selten genau einen Tag –, also kommen zwei Klicks schnell
|
||||||
|
// hintereinander. Ohne die Reihung ueberholten sich die beiden Anfragen, und
|
||||||
|
// welche zuletzt ankam, entschied: Der erste Tag fiel wieder ab.
|
||||||
|
const tagLauf = useRef({});
|
||||||
|
|
||||||
|
function tagsSetzen(tx, ids) {
|
||||||
|
const vorher = tx.tag_ids || [];
|
||||||
|
// Sofort anzeigen, nicht erst nach der Antwort: Sonst rechnet der zweite
|
||||||
|
// Klick noch mit dem Stand VOR dem ersten und nimmt ihn zurueck.
|
||||||
|
setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: ids } : x)));
|
||||||
|
const lauf = (tagLauf.current[tx.id] || Promise.resolve())
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => api.updateTransaction(tx.id, { tag_ids: ids.map(Number) }))
|
||||||
|
// Die Zeile ersetzen statt alles neu zu laden, sonst springt die
|
||||||
|
// Bildlaufposition bei jedem Klick nach oben.
|
||||||
|
.then((neu) => setTxns((r) => r.map((x) => (x.id === tx.id ? neu : x))))
|
||||||
|
.catch((err) => {
|
||||||
|
setTxns((r) => r.map((x) => (x.id === tx.id ? { ...x, tag_ids: vorher } : x)));
|
||||||
|
toast(err.message, "error");
|
||||||
|
});
|
||||||
|
tagLauf.current[tx.id] = lauf;
|
||||||
|
return lauf;
|
||||||
|
}
|
||||||
|
|
||||||
async function verbinden(tx) {
|
async function verbinden(tx) {
|
||||||
const gegen = vorschlag[tx.id];
|
const gegen = vorschlag[tx.id];
|
||||||
if (!gegen) return;
|
if (!gegen) return;
|
||||||
@@ -243,6 +278,7 @@ export default function Transactions() {
|
|||||||
payee: tx.payee || "",
|
payee: tx.payee || "",
|
||||||
description: tx.description || "",
|
description: tx.description || "",
|
||||||
notes: tx.notes || "",
|
notes: tx.notes || "",
|
||||||
|
tag_ids: tx.tag_ids || [],
|
||||||
budget_as_expense: tx.budget_treatment !== "neutral",
|
budget_as_expense: tx.budget_treatment !== "neutral",
|
||||||
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
|
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
|
||||||
// Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst:
|
// Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst:
|
||||||
@@ -271,6 +307,7 @@ export default function Transactions() {
|
|||||||
payee: form.payee.trim() || null,
|
payee: form.payee.trim() || null,
|
||||||
description: form.description.trim() || null,
|
description: form.description.trim() || null,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
|
tag_ids: form.tag_ids || [],
|
||||||
budget_period: form.budget_period || null,
|
budget_period: form.budget_period || null,
|
||||||
};
|
};
|
||||||
if (form.type === "transfer") {
|
if (form.type === "transfer") {
|
||||||
@@ -505,6 +542,20 @@ export default function Transactions() {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// filterValues statt filterText: Eine Zeile hat MEHRERE Tags, und die
|
||||||
|
// DataTable kann das – sie vergleicht dann gegen jeden einzelnen Wert.
|
||||||
|
key: "tags", header: t("tags.column"), width: 170,
|
||||||
|
filterValues: (tx) => tagsVon(tx).map((x) => x.name),
|
||||||
|
render: (tx) => (
|
||||||
|
<TagSelect
|
||||||
|
tags={tags}
|
||||||
|
value={tx.tag_ids || []}
|
||||||
|
onChange={(ids) => tagsSetzen(tx, ids)}
|
||||||
|
placeholder={t("tags.none")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "account", header: t("transactions.account"), width: 130,
|
key: "account", header: t("transactions.account"), width: 130,
|
||||||
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
||||||
@@ -582,7 +633,7 @@ export default function Transactions() {
|
|||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
||||||
formatDate, links, paarZu, vorschlag, abos, t]);
|
formatDate, links, paarZu, vorschlag, abos, tags, tagById, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -755,6 +806,17 @@ export default function Transactions() {
|
|||||||
placeholder={t("transactions.notesPlaceholder")}
|
placeholder={t("transactions.notesPlaceholder")}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
|
{/* Tags quer zur Kategorie: „Urlaub Island", „absetzbar". Mehrere
|
||||||
|
duerfen es sein, deshalb keine einfache Auswahl. */}
|
||||||
|
<label>
|
||||||
|
<span>{t("tags.column")}</span>
|
||||||
|
<TagSelect
|
||||||
|
tags={tags}
|
||||||
|
value={form.tag_ids}
|
||||||
|
onChange={(ids) => setForm({ ...form, tag_ids: ids })}
|
||||||
|
placeholder={t("tags.none")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+5
-3
@@ -225,7 +225,7 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
|
|||||||
|
|
||||||
/* ---------- Forms ---------- */
|
/* ---------- Forms ---------- */
|
||||||
label { display: block; margin-bottom: var(--sp-4); font-size: 0.8rem; color: var(--muted); font-weight: 580; }
|
label { display: block; margin-bottom: var(--sp-4); font-size: 0.8rem; color: var(--muted); font-weight: 580; }
|
||||||
input, select {
|
input, select, textarea {
|
||||||
width: 100%; margin-top: 5px;
|
width: 100%; margin-top: 5px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid var(--border-strong);
|
||||||
@@ -234,11 +234,13 @@ input, select {
|
|||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
transition: border-color .12s, box-shadow .12s;
|
transition: border-color .12s, box-shadow .12s;
|
||||||
}
|
}
|
||||||
input:focus, select:focus {
|
input:focus, select:focus, textarea:focus {
|
||||||
outline: none; border-color: var(--accent);
|
outline: none; border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
input::placeholder { color: var(--muted); opacity: 0.7; }
|
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
/* Nur in der Hoehe ziehbar: seitwaerts sprengte das Feld den Dialog. */
|
||||||
|
textarea { resize: vertical; min-height: 4.5em; line-height: 1.45; }
|
||||||
/* Ankreuzfelder sind keine Textfelder: die Regel oben (width:100%, margin-top)
|
/* Ankreuzfelder sind keine Textfelder: die Regel oben (width:100%, margin-top)
|
||||||
zoege sie sonst ueber die volle Breite, das Haekchen landete in der Mitte und
|
zoege sie sonst ueber die volle Breite, das Haekchen landete in der Mitte und
|
||||||
der Text daneben ganz rechts. Hier zentral ausgenommen, damit das nicht an
|
der Text daneben ganz rechts. Hier zentral ausgenommen, damit das nicht an
|
||||||
|
|||||||
Reference in New Issue
Block a user