mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +07:00
feat: add free-form tags on transactions
Third of the four ideas picked from the Maybe/Sure feature audit. Tags are orthogonal to categories (category = what kind of spend, tag = which event/project) — many-to-many, created inline while typing in the transaction form, filterable from the Transactions page. Also adds tests/test_backup.py, the first test coverage the backup export/import path has ever had, and fixes a MissingGreenlet bug it surfaced in restore_backup's tag-wiring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ from app.models import ( # noqa: F401 — registers metadata
|
|||||||
Goal,
|
Goal,
|
||||||
GoalContribution,
|
GoalContribution,
|
||||||
RecurringTransaction,
|
RecurringTransaction,
|
||||||
|
Tag,
|
||||||
Transaction,
|
Transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
41
backend/alembic/versions/f6a4d8b2e017_add_tags.py
Normal file
41
backend/alembic/versions/f6a4d8b2e017_add_tags.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""add tags and transaction_tags
|
||||||
|
|
||||||
|
Revision ID: f6a4d8b2e017
|
||||||
|
Revises: 8e2f5a13c9d4
|
||||||
|
Create Date: 2026-08-25 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f6a4d8b2e017'
|
||||||
|
down_revision: Union[str, None] = '8e2f5a13c9d4'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'tags',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=50), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name'),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
'transaction_tags',
|
||||||
|
sa.Column('transaction_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('transaction_id', 'tag_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('transaction_tags')
|
||||||
|
op.drop_table('tags')
|
||||||
41
backend/app/api/routes/tags.py
Normal file
41
backend/app/api/routes/tags.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_session
|
||||||
|
from app.models.tag import Tag
|
||||||
|
from app.schemas.tag import TagCreate, TagRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TagRead])
|
||||||
|
async def list_tags(session: AsyncSession = Depends(get_session)) -> list[Tag]:
|
||||||
|
result = await session.execute(select(Tag).order_by(Tag.name))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TagRead, status_code=201)
|
||||||
|
async def create_tag(payload: TagCreate, session: AsyncSession = Depends(get_session)) -> Tag:
|
||||||
|
# Case-insensitive dedup — the frontend's tag picker creates tags
|
||||||
|
# on the fly as the user types, so "Georgia" and "georgia" typed on two
|
||||||
|
# different transactions should end up as the same tag, not two.
|
||||||
|
name = payload.name.strip()
|
||||||
|
existing = await session.execute(select(Tag).where(func.lower(Tag.name) == name.lower()))
|
||||||
|
tag = existing.scalar_one_or_none()
|
||||||
|
if tag is not None:
|
||||||
|
return tag
|
||||||
|
tag = Tag(name=name)
|
||||||
|
session.add(tag)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tag)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tag_id}", status_code=204)
|
||||||
|
async def delete_tag(tag_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||||
|
tag = await session.get(Tag, tag_id)
|
||||||
|
if tag is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
|
await session.delete(tag)
|
||||||
|
await session.commit()
|
||||||
@@ -9,12 +9,24 @@ from sqlalchemy.orm import selectinload
|
|||||||
from app.api.deps import get_session
|
from app.api.deps import get_session
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
|
from app.models.tag import Tag
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
|
from app.schemas.transaction import TransactionCreate, TransactionPage, TransactionRead, TransactionUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||||
|
|
||||||
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category))
|
_EAGER = (selectinload(Transaction.account), selectinload(Transaction.category), selectinload(Transaction.tags))
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_tags(session: AsyncSession, tag_ids: list[int]) -> list[Tag]:
|
||||||
|
if not tag_ids:
|
||||||
|
return []
|
||||||
|
result = await session.execute(select(Tag).where(Tag.id.in_(tag_ids)))
|
||||||
|
tags = list(result.scalars().all())
|
||||||
|
missing = set(tag_ids) - {tag.id for tag in tags}
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown tag id(s): {sorted(missing)}")
|
||||||
|
return tags
|
||||||
|
|
||||||
_TYPE_TO_CATEGORY_KIND = {
|
_TYPE_TO_CATEGORY_KIND = {
|
||||||
TransactionType.INCOME: CategoryKind.INCOME,
|
TransactionType.INCOME: CategoryKind.INCOME,
|
||||||
@@ -50,6 +62,7 @@ async def list_transactions(
|
|||||||
end_date: date_ | None = Query(default=None),
|
end_date: date_ | None = Query(default=None),
|
||||||
account_id: int | None = None,
|
account_id: int | None = None,
|
||||||
category_id: int | None = None,
|
category_id: int | None = None,
|
||||||
|
tag_id: int | None = None,
|
||||||
type: TransactionType | None = None,
|
type: TransactionType | None = None,
|
||||||
search: str | None = Query(default=None, min_length=1, max_length=255),
|
search: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
sort: Literal["date_desc", "amount_desc", "amount_asc"] = Query(default="date_desc"),
|
||||||
@@ -78,6 +91,9 @@ async def list_transactions(
|
|||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
stmt = stmt.where(Transaction.category_id == category_id)
|
stmt = stmt.where(Transaction.category_id == category_id)
|
||||||
count_stmt = count_stmt.where(Transaction.category_id == category_id)
|
count_stmt = count_stmt.where(Transaction.category_id == category_id)
|
||||||
|
if tag_id is not None:
|
||||||
|
stmt = stmt.where(Transaction.tags.any(Tag.id == tag_id))
|
||||||
|
count_stmt = count_stmt.where(Transaction.tags.any(Tag.id == tag_id))
|
||||||
if type is not None:
|
if type is not None:
|
||||||
stmt = stmt.where(Transaction.type == type)
|
stmt = stmt.where(Transaction.type == type)
|
||||||
count_stmt = count_stmt.where(Transaction.type == type)
|
count_stmt = count_stmt.where(Transaction.type == type)
|
||||||
@@ -127,7 +143,9 @@ async def list_transaction_years(session: AsyncSession = Depends(get_session)) -
|
|||||||
@router.post("", response_model=TransactionRead, status_code=201)
|
@router.post("", response_model=TransactionRead, status_code=201)
|
||||||
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
async def create_transaction(payload: TransactionCreate, session: AsyncSession = Depends(get_session)) -> Transaction:
|
||||||
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
await _ensure_category_matches_type(session, payload.category_id, payload.type)
|
||||||
transaction = Transaction(**payload.model_dump())
|
fields = payload.model_dump(exclude={"tag_ids"})
|
||||||
|
transaction = Transaction(**fields)
|
||||||
|
transaction.tags = await _resolve_tags(session, payload.tag_ids)
|
||||||
session.add(transaction)
|
session.add(transaction)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
refreshed = await session.execute(
|
refreshed = await session.execute(
|
||||||
@@ -140,15 +158,20 @@ async def create_transaction(payload: TransactionCreate, session: AsyncSession =
|
|||||||
async def update_transaction(
|
async def update_transaction(
|
||||||
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
transaction_id: int, payload: TransactionUpdate, session: AsyncSession = Depends(get_session)
|
||||||
) -> Transaction:
|
) -> Transaction:
|
||||||
transaction = await session.get(Transaction, transaction_id)
|
# Eager-loads tags — assigning transaction.tags below would otherwise lazy
|
||||||
|
# -load the current collection first to diff against, which async
|
||||||
|
# SQLAlchemy can't do outside an explicit await (MissingGreenlet).
|
||||||
|
transaction = await session.get(Transaction, transaction_id, options=[selectinload(Transaction.tags)])
|
||||||
if transaction is None:
|
if transaction is None:
|
||||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
updates = payload.model_dump(exclude_unset=True)
|
updates = payload.model_dump(exclude_unset=True, exclude={"tag_ids"})
|
||||||
effective_type = updates.get("type", transaction.type)
|
effective_type = updates.get("type", transaction.type)
|
||||||
effective_category_id = updates.get("category_id", transaction.category_id)
|
effective_category_id = updates.get("category_id", transaction.category_id)
|
||||||
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
await _ensure_category_matches_type(session, effective_category_id, effective_type)
|
||||||
for field, value in updates.items():
|
for field, value in updates.items():
|
||||||
setattr(transaction, field, value)
|
setattr(transaction, field, value)
|
||||||
|
if payload.tag_ids is not None:
|
||||||
|
transaction.tags = await _resolve_tags(session, payload.tag_ids)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
refreshed = await session.execute(
|
refreshed = await session.execute(
|
||||||
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
|
select(Transaction).options(*_EAGER).where(Transaction.id == transaction_id)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.api.routes import (
|
|||||||
recurring,
|
recurring,
|
||||||
reports,
|
reports,
|
||||||
settings as settings_routes,
|
settings as settings_routes,
|
||||||
|
tags,
|
||||||
transactions,
|
transactions,
|
||||||
)
|
)
|
||||||
from app.core.config import APP_VERSION, get_settings
|
from app.core.config import APP_VERSION, get_settings
|
||||||
@@ -61,6 +62,7 @@ app.include_router(advice.router, prefix="/api")
|
|||||||
app.include_router(goals.router, prefix="/api")
|
app.include_router(goals.router, prefix="/api")
|
||||||
app.include_router(recurring.router, prefix="/api")
|
app.include_router(recurring.router, prefix="/api")
|
||||||
app.include_router(cash_flow.router, prefix="/api")
|
app.include_router(cash_flow.router, prefix="/api")
|
||||||
|
app.include_router(tags.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.models.category import Category
|
|||||||
from app.models.goal import Goal, GoalContribution
|
from app.models.goal import Goal, GoalContribution
|
||||||
from app.models.recurring import RecurringTransaction
|
from app.models.recurring import RecurringTransaction
|
||||||
from app.models.settings import AppSettings
|
from app.models.settings import AppSettings
|
||||||
|
from app.models.tag import Tag
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -17,5 +18,6 @@ __all__ = [
|
|||||||
"Goal",
|
"Goal",
|
||||||
"GoalContribution",
|
"GoalContribution",
|
||||||
"RecurringTransaction",
|
"RecurringTransaction",
|
||||||
|
"Tag",
|
||||||
"Transaction",
|
"Transaction",
|
||||||
]
|
]
|
||||||
|
|||||||
26
backend/app/models/tag.py
Normal file
26
backend/app/models/tag.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""A free-form, reusable label on transactions — orthogonal to Category:
|
||||||
|
a category answers "what kind of spend is this", a tag answers "which
|
||||||
|
event/project does it belong to" (e.g. "trip:georgia", "tax-deductible").
|
||||||
|
A transaction can carry any number of tags, a tag can be reused across any
|
||||||
|
number of transactions — see transaction_tags below.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, ForeignKey, String, Table
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
transaction_tags = Table(
|
||||||
|
"transaction_tags",
|
||||||
|
Base.metadata,
|
||||||
|
Column("transaction_id", ForeignKey("transactions.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
__tablename__ = "tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
|
||||||
|
|
||||||
|
transactions: Mapped[list["Transaction"]] = relationship(secondary=transaction_tags, back_populates="tags")
|
||||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.enums import TransactionType
|
from app.models.enums import TransactionType
|
||||||
from app.models.mixins import TimestampMixin
|
from app.models.mixins import TimestampMixin
|
||||||
|
from app.models.tag import transaction_tags
|
||||||
|
|
||||||
|
|
||||||
class Transaction(Base, TimestampMixin):
|
class Transaction(Base, TimestampMixin):
|
||||||
@@ -33,3 +34,4 @@ class Transaction(Base, TimestampMixin):
|
|||||||
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
|
account: Mapped["Account"] = relationship(back_populates="transactions", foreign_keys=[account_id])
|
||||||
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
|
transfer_account: Mapped["Account | None"] = relationship(foreign_keys=[transfer_account_id])
|
||||||
category: Mapped["Category | None"] = relationship(back_populates="transactions")
|
category: Mapped["Category | None"] = relationship(back_populates="transactions")
|
||||||
|
tags: Mapped[list["Tag"]] = relationship(secondary=transaction_tags, back_populates="transactions")
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ class CategoryBackup(BaseModel):
|
|||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TagBackup(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class TransactionBackup(BaseModel):
|
class TransactionBackup(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -55,6 +62,11 @@ class TransactionBackup(BaseModel):
|
|||||||
merchant: str | None
|
merchant: str | None
|
||||||
notes: str | None
|
notes: str | None
|
||||||
date: date_
|
date: date_
|
||||||
|
# Defaulted so a backup exported before tags existed still imports
|
||||||
|
# cleanly under the same format version. Not a plain column — populated
|
||||||
|
# explicitly in build_backup() from the `tags` relationship, since
|
||||||
|
# from_attributes can't map a `tags` relationship to a `tag_ids` field.
|
||||||
|
tag_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AssetBackup(BaseModel):
|
class AssetBackup(BaseModel):
|
||||||
@@ -155,6 +167,9 @@ class BackupPayload(BaseModel):
|
|||||||
app_version: str
|
app_version: str
|
||||||
accounts: list[AccountBackup]
|
accounts: list[AccountBackup]
|
||||||
categories: list[CategoryBackup]
|
categories: list[CategoryBackup]
|
||||||
|
# Defaulted so a backup exported before tags existed still imports
|
||||||
|
# cleanly under the same format version.
|
||||||
|
tags: list[TagBackup] = Field(default_factory=list)
|
||||||
transactions: list[TransactionBackup]
|
transactions: list[TransactionBackup]
|
||||||
assets: list[AssetBackup]
|
assets: list[AssetBackup]
|
||||||
asset_valuations: list[AssetValuationBackup]
|
asset_valuations: list[AssetValuationBackup]
|
||||||
|
|||||||
12
backend/app/schemas/tag.py
Normal file
12
backend/app/schemas/tag.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TagCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class TagRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
@@ -7,6 +7,7 @@ from app.core.text import capitalize_first_letter
|
|||||||
from app.models.enums import TransactionType
|
from app.models.enums import TransactionType
|
||||||
from app.schemas.account import AccountRead
|
from app.schemas.account import AccountRead
|
||||||
from app.schemas.category import CategoryRead
|
from app.schemas.category import CategoryRead
|
||||||
|
from app.schemas.tag import TagRead
|
||||||
|
|
||||||
|
|
||||||
class TransactionBase(BaseModel):
|
class TransactionBase(BaseModel):
|
||||||
@@ -41,7 +42,7 @@ class TransactionBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class TransactionCreate(TransactionBase):
|
class TransactionCreate(TransactionBase):
|
||||||
pass
|
tag_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TransactionUpdate(BaseModel):
|
class TransactionUpdate(BaseModel):
|
||||||
@@ -54,6 +55,8 @@ class TransactionUpdate(BaseModel):
|
|||||||
merchant: str | None = Field(default=None, max_length=150)
|
merchant: str | None = Field(default=None, max_length=150)
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
date: date_ | None = None
|
date: date_ | None = None
|
||||||
|
# Omitted -> tags untouched; sent (even as []) -> replaces the full tag set.
|
||||||
|
tag_ids: list[int] | None = None
|
||||||
|
|
||||||
@field_validator("description")
|
@field_validator("description")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -67,6 +70,7 @@ class TransactionRead(TransactionBase):
|
|||||||
id: int
|
id: int
|
||||||
account: AccountRead
|
account: AccountRead
|
||||||
category: CategoryRead | None = None
|
category: CategoryRead | None = None
|
||||||
|
tags: list[TagRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class TransactionPage(BaseModel):
|
class TransactionPage(BaseModel):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete, select, text
|
from sqlalchemy import delete, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.config import APP_VERSION
|
from app.core.config import APP_VERSION
|
||||||
from app.models.account import Account
|
from app.models.account import Account
|
||||||
@@ -23,6 +24,7 @@ from app.models.category import Category
|
|||||||
from app.models.goal import Goal, GoalContribution
|
from app.models.goal import Goal, GoalContribution
|
||||||
from app.models.recurring import RecurringTransaction
|
from app.models.recurring import RecurringTransaction
|
||||||
from app.models.settings import AppSettings
|
from app.models.settings import AppSettings
|
||||||
|
from app.models.tag import Tag
|
||||||
from app.models.transaction import Transaction
|
from app.models.transaction import Transaction
|
||||||
from app.schemas.backup import (
|
from app.schemas.backup import (
|
||||||
AccountBackup,
|
AccountBackup,
|
||||||
@@ -35,6 +37,7 @@ from app.schemas.backup import (
|
|||||||
GoalBackup,
|
GoalBackup,
|
||||||
GoalContributionBackup,
|
GoalContributionBackup,
|
||||||
RecurringTransactionBackup,
|
RecurringTransactionBackup,
|
||||||
|
TagBackup,
|
||||||
TransactionBackup,
|
TransactionBackup,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,7 +47,8 @@ BACKUP_FORMAT_VERSION = 1
|
|||||||
async def build_backup(session: AsyncSession) -> BackupPayload:
|
async def build_backup(session: AsyncSession) -> BackupPayload:
|
||||||
accounts = (await session.execute(select(Account))).scalars().all()
|
accounts = (await session.execute(select(Account))).scalars().all()
|
||||||
categories = (await session.execute(select(Category))).scalars().all()
|
categories = (await session.execute(select(Category))).scalars().all()
|
||||||
transactions = (await session.execute(select(Transaction))).scalars().all()
|
tags = (await session.execute(select(Tag))).scalars().all()
|
||||||
|
transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all()
|
||||||
assets = (await session.execute(select(Asset))).scalars().all()
|
assets = (await session.execute(select(Asset))).scalars().all()
|
||||||
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
||||||
budgets = (await session.execute(select(Budget))).scalars().all()
|
budgets = (await session.execute(select(Budget))).scalars().all()
|
||||||
@@ -59,7 +63,13 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
|||||||
app_version=APP_VERSION,
|
app_version=APP_VERSION,
|
||||||
accounts=[AccountBackup.model_validate(row) for row in accounts],
|
accounts=[AccountBackup.model_validate(row) for row in accounts],
|
||||||
categories=[CategoryBackup.model_validate(row) for row in categories],
|
categories=[CategoryBackup.model_validate(row) for row in categories],
|
||||||
transactions=[TransactionBackup.model_validate(row) for row in transactions],
|
tags=[TagBackup.model_validate(row) for row in tags],
|
||||||
|
transactions=[
|
||||||
|
TransactionBackup.model_validate(row, from_attributes=True).model_copy(
|
||||||
|
update={"tag_ids": [tag.id for tag in row.tags]}
|
||||||
|
)
|
||||||
|
for row in transactions
|
||||||
|
],
|
||||||
assets=[AssetBackup.model_validate(row) for row in assets],
|
assets=[AssetBackup.model_validate(row) for row in assets],
|
||||||
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
||||||
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
||||||
@@ -74,6 +84,7 @@ def _validate_references(payload: BackupPayload) -> None:
|
|||||||
account_ids = {row.id for row in payload.accounts}
|
account_ids = {row.id for row in payload.accounts}
|
||||||
category_ids = {row.id for row in payload.categories}
|
category_ids = {row.id for row in payload.categories}
|
||||||
asset_ids = {row.id for row in payload.assets}
|
asset_ids = {row.id for row in payload.assets}
|
||||||
|
tag_ids = {row.id for row in payload.tags}
|
||||||
|
|
||||||
for c in payload.categories:
|
for c in payload.categories:
|
||||||
if c.parent_id is not None and c.parent_id not in category_ids:
|
if c.parent_id is not None and c.parent_id not in category_ids:
|
||||||
@@ -88,6 +99,9 @@ def _validate_references(payload: BackupPayload) -> None:
|
|||||||
)
|
)
|
||||||
if t.category_id is not None and t.category_id not in category_ids:
|
if t.category_id is not None and t.category_id not in category_ids:
|
||||||
raise HTTPException(400, f"Transaction {t.id} references unknown category_id {t.category_id}")
|
raise HTTPException(400, f"Transaction {t.id} references unknown category_id {t.category_id}")
|
||||||
|
for tag_id in t.tag_ids:
|
||||||
|
if tag_id not in tag_ids:
|
||||||
|
raise HTTPException(400, f"Transaction {t.id} references unknown tag_id {tag_id}")
|
||||||
|
|
||||||
for v in payload.asset_valuations:
|
for v in payload.asset_valuations:
|
||||||
if v.asset_id not in asset_ids:
|
if v.asset_id not in asset_ids:
|
||||||
@@ -144,7 +158,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
await session.execute(delete(GoalContribution))
|
await session.execute(delete(GoalContribution))
|
||||||
await session.execute(delete(Goal))
|
await session.execute(delete(Goal))
|
||||||
await session.execute(delete(RecurringTransaction))
|
await session.execute(delete(RecurringTransaction))
|
||||||
|
# Deleting transactions cascades transaction_tags rows (ON DELETE
|
||||||
|
# CASCADE) — tags themselves still need their own delete.
|
||||||
await session.execute(delete(Transaction))
|
await session.execute(delete(Transaction))
|
||||||
|
await session.execute(delete(Tag))
|
||||||
await session.execute(delete(Asset))
|
await session.execute(delete(Asset))
|
||||||
await session.execute(delete(Category))
|
await session.execute(delete(Category))
|
||||||
await session.execute(delete(Account))
|
await session.execute(delete(Account))
|
||||||
@@ -158,7 +175,22 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
|
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
|
||||||
session.add_all(Category(**row.model_dump()) for row in categories_in_order)
|
session.add_all(Category(**row.model_dump()) for row in categories_in_order)
|
||||||
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
|
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
|
||||||
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions)
|
|
||||||
|
# Tags and transactions are kept in id-keyed dicts (rather than a
|
||||||
|
# plain add_all) — transaction.tags is a relationship, not a column
|
||||||
|
# in model_dump(), so it has to be wired up from live ORM objects
|
||||||
|
# once everything is flushed and has real identities.
|
||||||
|
tags_by_id = {row.id: Tag(**row.model_dump()) for row in payload.tags}
|
||||||
|
session.add_all(tags_by_id.values())
|
||||||
|
# tags=[] at construction keeps the collection "loaded" on the
|
||||||
|
# transient object — reassigning it after flush (below) would
|
||||||
|
# otherwise trigger an implicit lazy-load, which async SQLAlchemy
|
||||||
|
# can't do outside an explicit await (MissingGreenlet).
|
||||||
|
transactions_by_id = {
|
||||||
|
row.id: Transaction(**row.model_dump(exclude={"tag_ids"}), tags=[]) for row in payload.transactions
|
||||||
|
}
|
||||||
|
session.add_all(transactions_by_id.values())
|
||||||
|
|
||||||
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
||||||
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
|
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
|
||||||
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
|
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
|
||||||
@@ -166,8 +198,15 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions)
|
session.add_all(RecurringTransaction(**row.model_dump()) for row in payload.recurring_transactions)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
|
for row in payload.transactions:
|
||||||
|
if row.tag_ids:
|
||||||
|
transactions_by_id[row.id].tags = [tags_by_id[tag_id] for tag_id in row.tag_ids]
|
||||||
|
if any(row.tag_ids for row in payload.transactions):
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
await _reset_sequence(session, "accounts", payload.accounts)
|
await _reset_sequence(session, "accounts", payload.accounts)
|
||||||
await _reset_sequence(session, "categories", payload.categories)
|
await _reset_sequence(session, "categories", payload.categories)
|
||||||
|
await _reset_sequence(session, "tags", payload.tags)
|
||||||
await _reset_sequence(session, "assets", payload.assets)
|
await _reset_sequence(session, "assets", payload.assets)
|
||||||
await _reset_sequence(session, "transactions", payload.transactions)
|
await _reset_sequence(session, "transactions", payload.transactions)
|
||||||
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
||||||
|
|||||||
64
backend/tests/test_backup.py
Normal file
64
backend/tests/test_backup.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Full-database backup export/import (services/backup_service.py) — only
|
||||||
|
the parts this change touched: subcategories (self-referential parent_id)
|
||||||
|
and tags (many-to-many) surviving a round trip.
|
||||||
|
"""
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.helpers import txn_payload as _txn
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_roundtrip_preserves_subcategories_and_tags(client: AsyncClient, account_id, categories):
|
||||||
|
parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"})
|
||||||
|
parent_id = parent.json()["id"]
|
||||||
|
child = await client.post(
|
||||||
|
"/categories", json={"name": "Custom Child", "kind": "expense", "color": "#e34948", "parent_id": parent_id}
|
||||||
|
)
|
||||||
|
child_id = child.json()["id"]
|
||||||
|
|
||||||
|
tag = (await client.post("/tags", json={"name": "Roundtrip"})).json()["id"]
|
||||||
|
created = await client.post("/transactions", json=_txn(account_id, category_id=child_id, tag_ids=[tag]))
|
||||||
|
txn_id = created.json()["id"]
|
||||||
|
|
||||||
|
export_resp = await client.get("/backup/export")
|
||||||
|
assert export_resp.status_code == 200
|
||||||
|
payload = export_resp.json()
|
||||||
|
|
||||||
|
import_resp = await client.post("/backup/import", json=payload)
|
||||||
|
assert import_resp.status_code == 200, import_resp.text
|
||||||
|
|
||||||
|
refetched_categories = {c["id"]: c for c in (await client.get("/categories")).json()}
|
||||||
|
assert refetched_categories[child_id]["parent_id"] == parent_id
|
||||||
|
|
||||||
|
refetched_txn = next(t for t in (await client.get("/transactions")).json()["items"] if t["id"] == txn_id)
|
||||||
|
assert [t["id"] for t in refetched_txn["tags"]] == [tag]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_backup_import_rejects_transaction_with_unknown_tag_id(client: AsyncClient, account_id, categories):
|
||||||
|
export_resp = await client.get("/backup/export")
|
||||||
|
payload = export_resp.json()
|
||||||
|
|
||||||
|
created = await client.post(
|
||||||
|
"/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"], date="2026-01-01")
|
||||||
|
)
|
||||||
|
payload["transactions"] = (await client.get("/transactions")).json()["items"]
|
||||||
|
# Reshape to the backup wire format (flat FKs, not nested account/category
|
||||||
|
# objects) and inject a tag_id that doesn't exist in payload["tags"].
|
||||||
|
payload["transactions"] = [
|
||||||
|
{
|
||||||
|
"id": t["id"],
|
||||||
|
"account_id": t["account_id"],
|
||||||
|
"category_id": t["category_id"],
|
||||||
|
"transfer_account_id": t["transfer_account_id"],
|
||||||
|
"type": t["type"],
|
||||||
|
"amount": t["amount"],
|
||||||
|
"description": t["description"],
|
||||||
|
"merchant": t["merchant"],
|
||||||
|
"notes": t["notes"],
|
||||||
|
"date": t["date"],
|
||||||
|
"tag_ids": [999999] if t["id"] == created.json()["id"] else [],
|
||||||
|
}
|
||||||
|
for t in payload["transactions"]
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = await client.post("/backup/import", json=payload)
|
||||||
|
assert resp.status_code == 400
|
||||||
80
backend/tests/test_tags.py
Normal file
80
backend/tests/test_tags.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Tags: CRUD, case-insensitive dedup on create, and their use on
|
||||||
|
transactions (create/update with tag_ids, filtering by tag_id).
|
||||||
|
"""
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.helpers import txn_payload as _txn
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_tag(client: AsyncClient):
|
||||||
|
resp = await client.post("/tags", json={"name": "Georgia Trip"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["name"] == "Georgia Trip"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_tag_dedups_case_insensitively(client: AsyncClient):
|
||||||
|
first = await client.post("/tags", json={"name": "Tax Deductible"})
|
||||||
|
second = await client.post("/tags", json={"name": "tax deductible"})
|
||||||
|
|
||||||
|
assert first.json()["id"] == second.json()["id"]
|
||||||
|
assert len((await client.get("/tags")).json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_tag(client: AsyncClient):
|
||||||
|
created = await client.post("/tags", json={"name": "Temp"})
|
||||||
|
resp = await client.delete(f"/tags/{created.json()['id']}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert (await client.get("/tags")).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_transaction_with_tags(client: AsyncClient, account_id, categories):
|
||||||
|
tag = await client.post("/tags", json={"name": "Georgia"})
|
||||||
|
tag_id = tag.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[tag_id]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert [t["id"] for t in resp.json()["tags"]] == [tag_id]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_transaction_replaces_tags(client: AsyncClient, account_id, categories):
|
||||||
|
tag_a = (await client.post("/tags", json={"name": "A"})).json()["id"]
|
||||||
|
tag_b = (await client.post("/tags", json={"name": "B"})).json()["id"]
|
||||||
|
created = await client.post(
|
||||||
|
"/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[tag_a])
|
||||||
|
)
|
||||||
|
txn_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.patch(f"/transactions/{txn_id}", json={"tag_ids": [tag_b]})
|
||||||
|
assert [t["id"] for t in resp.json()["tags"]] == [tag_b]
|
||||||
|
|
||||||
|
# Omitting tag_ids entirely leaves the existing tags untouched.
|
||||||
|
resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"})
|
||||||
|
assert [t["id"] for t in resp.json()["tags"]] == [tag_b]
|
||||||
|
|
||||||
|
# Sending an empty list clears them.
|
||||||
|
resp = await client.patch(f"/transactions/{txn_id}", json={"tag_ids": []})
|
||||||
|
assert resp.json()["tags"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_transaction_rejects_unknown_tag_id(client: AsyncClient, account_id, categories):
|
||||||
|
resp = await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[999999]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filter_transactions_by_tag(client: AsyncClient, account_id, categories):
|
||||||
|
tag = (await client.post("/tags", json={"name": "Filtered"})).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
"/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"], tag_ids=[tag])
|
||||||
|
)
|
||||||
|
await client.post("/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"]))
|
||||||
|
|
||||||
|
resp = await client.get("/transactions", params={"tag_id": tag})
|
||||||
|
body = resp.json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
assert body["items"][0]["tags"][0]["id"] == tag
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.24.0",
|
"version": "0.25.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
14
frontend/src/api/tags.ts
Normal file
14
frontend/src/api/tags.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { api } from "@/api/client";
|
||||||
|
import type { Tag } from "@/types";
|
||||||
|
|
||||||
|
export function fetchTags() {
|
||||||
|
return api.get<Tag[]>("/tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTag(name: string) {
|
||||||
|
return api.post<Tag>("/tags", { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTag(id: number) {
|
||||||
|
return api.delete<void>(`/tags/${id}`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export interface TransactionFilters {
|
|||||||
end_date?: string;
|
end_date?: string;
|
||||||
account_id?: number;
|
account_id?: number;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
|
tag_id?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: TransactionSort;
|
sort?: TransactionSort;
|
||||||
|
|||||||
132
frontend/src/components/transactions/TagInput.tsx
Normal file
132
frontend/src/components/transactions/TagInput.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useCreateTag, useTags } from "@/hooks/useTags";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Tag } from "@/types";
|
||||||
|
|
||||||
|
interface TagInputProps {
|
||||||
|
value: Tag[];
|
||||||
|
onChange: (tags: Tag[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free-text, multi-select tag picker with inline create — types like an
|
||||||
|
// autocomplete, Enter either picks the top matching existing tag or creates
|
||||||
|
// a new one (the backend dedupes case-insensitively either way, see
|
||||||
|
// routes/tags.py). No dedicated tag-management page yet: renaming/deleting a
|
||||||
|
// tag globally isn't exposed here, only removing it from this transaction.
|
||||||
|
export function TagInput({ value, onChange }: TagInputProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: allTags } = useTags();
|
||||||
|
const createTag = useCreateTag();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const selectedIds = new Set(value.map((tag) => tag.id));
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
const suggestions = (allTags ?? [])
|
||||||
|
.filter((tag) => !selectedIds.has(tag.id))
|
||||||
|
.filter((tag) => (trimmedQuery ? tag.name.toLowerCase().includes(trimmedQuery.toLowerCase()) : true))
|
||||||
|
.slice(0, 8);
|
||||||
|
const exactMatch = (allTags ?? []).find((tag) => tag.name.toLowerCase() === trimmedQuery.toLowerCase());
|
||||||
|
|
||||||
|
function addTag(tag: Tag) {
|
||||||
|
onChange([...value, tag]);
|
||||||
|
setQuery("");
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tagId: number) {
|
||||||
|
onChange(value.filter((tag) => tag.id !== tagId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitQuery() {
|
||||||
|
if (!trimmedQuery) return;
|
||||||
|
if (exactMatch) {
|
||||||
|
addTag(exactMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await createTag.mutateAsync(trimmedQuery);
|
||||||
|
addTag(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (suggestions[0]) addTag(suggestions[0]);
|
||||||
|
else void commitQuery();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
} else if (event.key === "Backspace" && !query && value.length > 0) {
|
||||||
|
removeTag(value[value.length - 1].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-surface-1 px-2 py-1.5 focus-within:border-series-1">
|
||||||
|
{value.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-surface-2 py-0.5 pl-2 pr-1 text-xs text-text-secondary"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("transactions.form.removeTag", { name: tag.name })}
|
||||||
|
onClick={() => removeTag(tag.id)}
|
||||||
|
className="rounded-full p-0.5 text-text-muted hover:bg-surface-1 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="h-6 min-w-[8ch] flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||||
|
value={query}
|
||||||
|
placeholder={value.length === 0 ? t("transactions.form.tagsPlaceholder") : ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
setQuery(event.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onBlur={() => setTimeout(() => setOpen(false), 100)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (suggestions.length > 0 || (trimmedQuery && !exactMatch)) && (
|
||||||
|
<ul className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg border border-border bg-surface-1 shadow-lg">
|
||||||
|
{suggestions.map((tag) => (
|
||||||
|
<li key={tag.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => addTag(tag)}
|
||||||
|
className={cn("block w-full px-3 py-1.5 text-left text-sm text-text-primary hover:bg-surface-2")}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{trimmedQuery && !exactMatch && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => void commitQuery()}
|
||||||
|
className="block w-full px-3 py-1.5 text-left text-sm text-series-1 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("transactions.form.createTag", { name: trimmedQuery })}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,13 @@ import { useEffect, useState } from "react";
|
|||||||
import { Dialog } from "@/components/ui/Dialog";
|
import { Dialog } from "@/components/ui/Dialog";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Input, Label, Select } from "@/components/ui/Input";
|
import { Input, Label, Select } from "@/components/ui/Input";
|
||||||
|
import { TagInput } from "@/components/transactions/TagInput";
|
||||||
import { useAccounts } from "@/hooks/useAccounts";
|
import { useAccounts } from "@/hooks/useAccounts";
|
||||||
import { useCategories } from "@/hooks/useCategories";
|
import { useCategories } from "@/hooks/useCategories";
|
||||||
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import type { Category, Transaction, TransactionInput, TransactionType } from "@/types";
|
import type { Category, Tag, Transaction, TransactionInput, TransactionType } from "@/types";
|
||||||
|
|
||||||
interface TransactionFormModalProps {
|
interface TransactionFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -39,6 +40,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
const updateTransaction = useUpdateTransaction();
|
const updateTransaction = useUpdateTransaction();
|
||||||
|
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,8 +57,10 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
notes: transaction.notes ?? "",
|
notes: transaction.notes ?? "",
|
||||||
date: transaction.date,
|
date: transaction.date,
|
||||||
});
|
});
|
||||||
|
setTags(transaction.tags);
|
||||||
} else {
|
} else {
|
||||||
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
||||||
|
setTags([]);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [open, transaction, accounts]);
|
}, [open, transaction, accounts]);
|
||||||
@@ -104,6 +108,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
merchant: form.merchant || null,
|
merchant: form.merchant || null,
|
||||||
notes: form.notes || null,
|
notes: form.notes || null,
|
||||||
date: form.date,
|
date: form.date,
|
||||||
|
tag_ids: tags.map((tag) => tag.id),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -253,6 +258,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="transaction-tags">{t("transactions.form.tagsLabel")}</Label>
|
||||||
|
<TagInput value={tags} onChange={setTags} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-danger">{error}</p>}
|
{error && <p className="text-sm text-danger">{error}</p>}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
|
|||||||
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
|
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
|
||||||
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
||||||
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
|
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
|
||||||
|
{tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
25
frontend/src/hooks/useTags.ts
Normal file
25
frontend/src/hooks/useTags.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { createTag, deleteTag, fetchTags } from "@/api/tags";
|
||||||
|
|
||||||
|
export function useTags() {
|
||||||
|
return useQuery({ queryKey: ["tags"], queryFn: fetchTags });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTag() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (name: string) => createTag(name),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tags"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteTag() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteTag(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -138,6 +138,7 @@ const ru = {
|
|||||||
"transactions.income": "Доходы",
|
"transactions.income": "Доходы",
|
||||||
"transactions.transfer": "Переводы",
|
"transactions.transfer": "Переводы",
|
||||||
"transactions.allCategories": "Все категории",
|
"transactions.allCategories": "Все категории",
|
||||||
|
"transactions.allTags": "Все теги",
|
||||||
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
|
"transactions.failedToLoad": "Не удалось загрузить транзакции.",
|
||||||
"transactions.noneFound": "Транзакций не найдено.",
|
"transactions.noneFound": "Транзакций не найдено.",
|
||||||
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
|
"transactions.searchPlaceholder": "Поиск по описанию, продавцу или заметке…",
|
||||||
@@ -167,6 +168,10 @@ const ru = {
|
|||||||
"transactions.form.noCategory": "Без категории",
|
"transactions.form.noCategory": "Без категории",
|
||||||
"transactions.form.merchantLabel": "Продавец (необязательно)",
|
"transactions.form.merchantLabel": "Продавец (необязательно)",
|
||||||
"transactions.form.notesLabel": "Заметка (необязательно)",
|
"transactions.form.notesLabel": "Заметка (необязательно)",
|
||||||
|
"transactions.form.tagsLabel": "Теги (необязательно)",
|
||||||
|
"transactions.form.tagsPlaceholder": "Начните вводить, чтобы найти или создать тег…",
|
||||||
|
"transactions.form.removeTag": "Убрать тег «{{name}}»",
|
||||||
|
"transactions.form.createTag": "Создать тег «{{name}}»",
|
||||||
"transactions.viewNote": "Посмотреть заметку",
|
"transactions.viewNote": "Посмотреть заметку",
|
||||||
"transactions.noteDialogTitle": "Заметка",
|
"transactions.noteDialogTitle": "Заметка",
|
||||||
"transactions.form.errorSelectAccount": "Выберите счёт",
|
"transactions.form.errorSelectAccount": "Выберите счёт",
|
||||||
@@ -501,6 +506,7 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"transactions.income": "Income",
|
"transactions.income": "Income",
|
||||||
"transactions.transfer": "Transfers",
|
"transactions.transfer": "Transfers",
|
||||||
"transactions.allCategories": "All categories",
|
"transactions.allCategories": "All categories",
|
||||||
|
"transactions.allTags": "All tags",
|
||||||
"transactions.failedToLoad": "Failed to load transactions.",
|
"transactions.failedToLoad": "Failed to load transactions.",
|
||||||
"transactions.noneFound": "No transactions found.",
|
"transactions.noneFound": "No transactions found.",
|
||||||
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
|
"transactions.searchPlaceholder": "Search by description, merchant, or note…",
|
||||||
@@ -530,6 +536,10 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"transactions.form.noCategory": "No category",
|
"transactions.form.noCategory": "No category",
|
||||||
"transactions.form.merchantLabel": "Merchant (optional)",
|
"transactions.form.merchantLabel": "Merchant (optional)",
|
||||||
"transactions.form.notesLabel": "Note (optional)",
|
"transactions.form.notesLabel": "Note (optional)",
|
||||||
|
"transactions.form.tagsLabel": "Tags (optional)",
|
||||||
|
"transactions.form.tagsPlaceholder": "Type to find or create a tag…",
|
||||||
|
"transactions.form.removeTag": "Remove tag \"{{name}}\"",
|
||||||
|
"transactions.form.createTag": "Create tag \"{{name}}\"",
|
||||||
"transactions.viewNote": "View note",
|
"transactions.viewNote": "View note",
|
||||||
"transactions.noteDialogTitle": "Note",
|
"transactions.noteDialogTitle": "Note",
|
||||||
"transactions.form.errorSelectAccount": "Select an account",
|
"transactions.form.errorSelectAccount": "Select an account",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
|||||||
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
||||||
import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions";
|
import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions";
|
||||||
import { useCategories } from "@/hooks/useCategories";
|
import { useCategories } from "@/hooks/useCategories";
|
||||||
|
import { useTags } from "@/hooks/useTags";
|
||||||
import type { TransactionSort } from "@/api/transactions";
|
import type { TransactionSort } from "@/api/transactions";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
@@ -44,6 +45,7 @@ export function TransactionsPage() {
|
|||||||
const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1));
|
const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1));
|
||||||
const [type, setType] = useState<TransactionType | "">("");
|
const [type, setType] = useState<TransactionType | "">("");
|
||||||
const [categoryId, setCategoryId] = useState<string>("");
|
const [categoryId, setCategoryId] = useState<string>("");
|
||||||
|
const [tagId, setTagId] = useState<string>("");
|
||||||
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@ export function TransactionsPage() {
|
|||||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
|
const { data: tags } = useTags();
|
||||||
const { data: years } = useTransactionYears();
|
const { data: years } = useTransactionYears();
|
||||||
const { data, isLoading, isError } = useTransactions({
|
const { data, isLoading, isError } = useTransactions({
|
||||||
// A search looks for a purchase from an unknown month, so it must span
|
// A search looks for a purchase from an unknown month, so it must span
|
||||||
@@ -73,6 +76,7 @@ export function TransactionsPage() {
|
|||||||
search: isSearching ? search : undefined,
|
search: isSearching ? search : undefined,
|
||||||
type: type || undefined,
|
type: type || undefined,
|
||||||
category_id: categoryId ? Number(categoryId) : undefined,
|
category_id: categoryId ? Number(categoryId) : undefined,
|
||||||
|
tag_id: tagId ? Number(tagId) : undefined,
|
||||||
sort,
|
sort,
|
||||||
page,
|
page,
|
||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
@@ -193,6 +197,23 @@ export function TransactionsPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
{tags && tags.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={tagId}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTagId(event.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="sm:w-48"
|
||||||
|
>
|
||||||
|
<option value="">{t("transactions.allTags")}</option>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.id}>
|
||||||
|
{tag.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export interface CategoryUpdateInput {
|
|||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: number;
|
id: number;
|
||||||
account_id: number;
|
account_id: number;
|
||||||
@@ -70,6 +75,7 @@ export interface Transaction {
|
|||||||
date: string;
|
date: string;
|
||||||
account: Account;
|
account: Account;
|
||||||
category: Category | null;
|
category: Category | null;
|
||||||
|
tags: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransactionPage {
|
export interface TransactionPage {
|
||||||
@@ -89,6 +95,9 @@ export interface TransactionInput {
|
|||||||
merchant?: string | null;
|
merchant?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
date: string;
|
date: string;
|
||||||
|
// Omitted -> tags untouched on update; sent (even as []) -> replaces the
|
||||||
|
// full tag set. Always sent on create (defaults to []).
|
||||||
|
tag_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecurringTransaction {
|
export interface RecurringTransaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user