Compare commits
4
Commits
b1f5eb9a51
...
50fc0e3353
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50fc0e3353 | ||
|
|
a9ea0432d8 | ||
|
|
e8c18adafb | ||
|
|
14254b483e |
@@ -45,6 +45,7 @@ from .routers import (
|
||||
rates,
|
||||
settings as settings_router,
|
||||
subscriptions,
|
||||
tags,
|
||||
transactions,
|
||||
users,
|
||||
)
|
||||
@@ -214,6 +215,7 @@ api.include_router(settings_router.router)
|
||||
api.include_router(policy.router)
|
||||
api.include_router(accounts.router)
|
||||
api.include_router(categories.router)
|
||||
api.include_router(tags.router)
|
||||
api.include_router(transactions.router)
|
||||
api.include_router(budgets.router)
|
||||
api.include_router(dashboard.router)
|
||||
|
||||
@@ -1281,3 +1281,61 @@ class BankAccountLink(Base):
|
||||
except ValueError:
|
||||
return {}
|
||||
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,
|
||||
InboxItem,
|
||||
InboxStatus,
|
||||
Tag,
|
||||
Transaction,
|
||||
TransactionTag,
|
||||
TransferLink,
|
||||
TxnType,
|
||||
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],
|
||||
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")
|
||||
if base == "EUR":
|
||||
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,
|
||||
description=txn.description,
|
||||
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]:
|
||||
rows = db.query(Account.id, Account.currency).filter(Account.owner_id == user.id).all()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
@@ -184,7 +228,8 @@ def list_transactions(
|
||||
ccy = _currency_map(db, user)
|
||||
woher = _herkunft(db, user, rows)
|
||||
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")
|
||||
@@ -332,10 +377,14 @@ def create_transaction(
|
||||
)
|
||||
buchung.setze_betraege(db, txn, from_acc, to_acc)
|
||||
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.refresh(txn)
|
||||
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)
|
||||
@@ -344,7 +393,8 @@ def get_transaction(
|
||||
):
|
||||
txn = get_owned(db, Transaction, txn_id, 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)
|
||||
@@ -390,11 +440,16 @@ def update_transaction(
|
||||
else:
|
||||
txn.to_account_id = None
|
||||
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.refresh(txn)
|
||||
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):
|
||||
@@ -426,7 +481,8 @@ def make_transfer(txn_id: int, payload: TransactionTransferIn,
|
||||
db.commit()
|
||||
db.refresh(txn)
|
||||
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)
|
||||
|
||||
@@ -346,6 +346,28 @@ class SubscriptionListOut(BaseModel):
|
||||
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 ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -433,6 +455,9 @@ class TransactionCreate(BaseModel):
|
||||
payee: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
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):
|
||||
@@ -452,6 +477,10 @@ class TransactionUpdate(BaseModel):
|
||||
payee: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
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):
|
||||
@@ -486,6 +515,11 @@ class TransactionOut(BaseModel):
|
||||
payee: str | None
|
||||
description: 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):
|
||||
|
||||
@@ -55,7 +55,9 @@ from ..models import (
|
||||
LinkIgnored,
|
||||
PlannedIncomeLine,
|
||||
RecoveryCode,
|
||||
Tag,
|
||||
Transaction,
|
||||
TransactionTag,
|
||||
TransferLink,
|
||||
User,
|
||||
UserSetting,
|
||||
@@ -135,8 +137,14 @@ USER_TABLES: list[TableSpec] = [
|
||||
{"budget_period_id": "budget_periods", "category_id": "categories"},
|
||||
label="Budget-Monatswerte"),
|
||||
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",
|
||||
{"category_id": "categories"}, label="Schlagwortregeln"),
|
||||
{"category_id": "categories", "target_account_id": "accounts"},
|
||||
label="Schlagwortregeln"),
|
||||
TableSpec("user_settings", UserSetting, "user", has_id=False,
|
||||
label="Persönliche Einstellungen"),
|
||||
# Der Eingang MUSS mitgesichert werden: Er hält fest, welche Zahlungen
|
||||
@@ -154,6 +162,12 @@ USER_TABLES: list[TableSpec] = [
|
||||
TableSpec("link_ignored", LinkIgnored, "owner",
|
||||
{"a_txn_id": "transactions", "b_txn_id": "transactions"},
|
||||
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
|
||||
# Wiedereinspielen stillschweigend auf zu funktionieren. Gesichert wird nur
|
||||
# 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
|
||||
# die ganze Wiederherstellung kippt. Es gibt genau eins je Vertrag.
|
||||
"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: (
|
||||
("uid", r["source_uid"]) if r.get("source_uid")
|
||||
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)
|
||||
if schon is None:
|
||||
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(
|
||||
f"Auf '{ziel.name}' steht schon eine passende Buchung vom "
|
||||
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"{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.
|
||||
assert admin.get(f"/api/transactions/{raus['id']}").json()["type"] == "expense"
|
||||
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 Accounts from "./pages/Accounts";
|
||||
import Categories from "./pages/Categories";
|
||||
import Tags from "./pages/Tags";
|
||||
import Transactions from "./pages/Transactions";
|
||||
import Budget from "./pages/Budget";
|
||||
import Abos from "./pages/Abos";
|
||||
@@ -51,6 +52,7 @@ function Sidebar() {
|
||||
<NavItem to="/" end icon="dashboard" label={t("nav.overview")} />
|
||||
<NavItem to="/konten" icon="wallet" label={t("nav.accounts")} />
|
||||
<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="/eingang" icon="inbox" label={t("nav.todo")} />
|
||||
<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="/konten" element={<Protected><Accounts /></Protected>} />
|
||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||
<Route path="/tags" element={<Protected><Tags /></Protected>} />
|
||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||
{/* Ein Punkt statt zweier: Seit angebundene Konten selbst buchen,
|
||||
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 }),
|
||||
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
|
||||
rateStatus: () => request("/rates/status"),
|
||||
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-haken { color: var(--ok); flex: 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",
|
||||
"settings": "Einstellungen",
|
||||
"todo": "Zu erledigen",
|
||||
"abos": "Abos"
|
||||
"abos": "Abos",
|
||||
"tags": "Tags"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Bitte anmelden",
|
||||
@@ -242,6 +243,22 @@
|
||||
"autoTarget": "automatisch",
|
||||
"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": {
|
||||
"title": "Kategorien",
|
||||
"newGroup": "Neue Gruppe",
|
||||
|
||||
+18
-1
@@ -24,7 +24,8 @@
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"todo": "To do",
|
||||
"abos": "Subscriptions"
|
||||
"abos": "Subscriptions",
|
||||
"tags": "Tags"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Please sign in",
|
||||
@@ -242,6 +243,22 @@
|
||||
"autoTarget": "automatic",
|
||||
"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": {
|
||||
"title": "Categories",
|
||||
"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 { api } from "../api";
|
||||
import { useSettings } from "../settings";
|
||||
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
import IconChip from "../components/IconChip";
|
||||
import Money from "../components/Money";
|
||||
import TagSelect from "../components/TagSelect";
|
||||
import Modal from "../components/Modal";
|
||||
import BudgetZeitraum from "../components/BudgetZeitraum";
|
||||
import AccountSelect from "../components/AccountSelect";
|
||||
@@ -33,6 +34,7 @@ function emptyForm() {
|
||||
payee: "",
|
||||
description: "",
|
||||
notes: "",
|
||||
tag_ids: [],
|
||||
budget_as_expense: true,
|
||||
budget_category_id: "",
|
||||
// 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 [accounts, setAccounts] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [tags, setTags] = useState([]);
|
||||
const [filter, setFilter] = useState({ account_id: "", type: "" });
|
||||
const [form, setForm] = useState(null);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
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 m = {};
|
||||
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() {
|
||||
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);
|
||||
setGroups(cats);
|
||||
setTags(tagListe);
|
||||
} catch (err) {
|
||||
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) {
|
||||
const gegen = vorschlag[tx.id];
|
||||
if (!gegen) return;
|
||||
@@ -243,6 +278,7 @@ export default function Transactions() {
|
||||
payee: tx.payee || "",
|
||||
description: tx.description || "",
|
||||
notes: tx.notes || "",
|
||||
tag_ids: tx.tag_ids || [],
|
||||
budget_as_expense: tx.budget_treatment !== "neutral",
|
||||
budget_category_id: tx.budget_category_id ? String(tx.budget_category_id) : "",
|
||||
// Nur der von Hand gesetzte Monat gehoert ins Formular. Leer heisst:
|
||||
@@ -271,6 +307,7 @@ export default function Transactions() {
|
||||
payee: form.payee.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
tag_ids: form.tag_ids || [],
|
||||
budget_period: form.budget_period || null,
|
||||
};
|
||||
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,
|
||||
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
||||
@@ -582,7 +633,7 @@ export default function Transactions() {
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], [accountById, categoryById, baseCurrency, expenseGroups, incomeGroups,
|
||||
formatDate, links, paarZu, vorschlag, abos, t]);
|
||||
formatDate, links, paarZu, vorschlag, abos, tags, tagById, t]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -755,6 +806,17 @@ export default function Transactions() {
|
||||
placeholder={t("transactions.notesPlaceholder")}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</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>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
+5
-3
@@ -225,7 +225,7 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
|
||||
|
||||
/* ---------- Forms ---------- */
|
||||
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;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
@@ -234,11 +234,13 @@ input, select {
|
||||
background: var(--surface); color: var(--text);
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user