feat: портфели в разделе «Криптовалюта»

Криптовалюту теперь можно разделять на отдельные портфели (например,
долгосрочные инвестиции, накопления на недвижимость, спекулятивные
монеты) вместо одного общего списка — по тому же принципу, что уже
есть у счетов (Account): у портфеля есть имя, цветная метка и
архивация, каждая монета обязательно принадлежит одному портфелю,
один и тот же токен можно держать в нескольких портфелях сразу.
This commit is contained in:
ZProger
2026-09-04 01:39:18 +03:00
parent c2f59c8773
commit aefd6cc035
21 changed files with 933 additions and 50 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ pgdata/
# Other # Other
UPDATES.md UPDATES.md
temp/

View File

@@ -0,0 +1,55 @@
"""add crypto portfolios
Revision ID: b7d4e9f2a610
Revises: 7b535ef9e189
Create Date: 2026-09-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b7d4e9f2a610'
down_revision: Union[str, None] = '7b535ef9e189'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crypto_portfolios',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('is_archived', sa.Boolean(), nullable=False, server_default=sa.text('false')),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
# Every crypto holding needs a portfolio_id (see below) — seed one
# default portfolio up front so existing holdings have somewhere to
# land, same "row exists immediately after migrating" reasoning as
# 9f3a2d7c5e11's app_settings seed.
op.execute("INSERT INTO crypto_portfolios (id, name, color, is_archived) VALUES (1, 'Main Portfolio', '#2a78d6', false)")
op.execute("SELECT setval(pg_get_serial_sequence('crypto_portfolios', 'id'), 1)")
op.add_column('crypto_holdings', sa.Column('portfolio_id', sa.Integer(), nullable=True))
op.execute("UPDATE crypto_holdings SET portfolio_id = 1")
op.alter_column('crypto_holdings', 'portfolio_id', nullable=False)
op.create_foreign_key(
'fk_crypto_holdings_portfolio_id',
'crypto_holdings',
'crypto_portfolios',
['portfolio_id'],
['id'],
ondelete='RESTRICT',
)
def downgrade() -> None:
op.drop_constraint('fk_crypto_holdings_portfolio_id', 'crypto_holdings', type_='foreignkey')
op.drop_column('crypto_holdings', 'portfolio_id')
op.drop_table('crypto_portfolios')

View File

@@ -6,6 +6,9 @@ from app.schemas.crypto import (
CryptoHistoryResponse, CryptoHistoryResponse,
CryptoHoldingCreate, CryptoHoldingCreate,
CryptoHoldingRead, CryptoHoldingRead,
CryptoPortfolioCreate,
CryptoPortfolioRead,
CryptoPortfolioUpdate,
CryptoSearchResult, CryptoSearchResult,
CryptoSyncResult, CryptoSyncResult,
CryptoTransactionCreate, CryptoTransactionCreate,
@@ -16,31 +19,65 @@ from app.services.crypto_service import (
CRYPTO_RANGE_DAYS, CRYPTO_RANGE_DAYS,
add_transaction, add_transaction,
create_holding, create_holding,
create_portfolio,
delete_portfolio,
delete_transaction, delete_transaction,
get_crypto_history, get_crypto_history,
list_portfolios,
list_transactions, list_transactions,
refresh_prices, refresh_prices,
search_coins, search_coins,
update_portfolio,
update_transaction, update_transaction,
) )
router = APIRouter(prefix="/crypto", tags=["crypto"]) router = APIRouter(prefix="/crypto", tags=["crypto"])
@router.get("/portfolios", response_model=list[CryptoPortfolioRead])
async def read_portfolios(
include_archived: bool = False, session: AsyncSession = Depends(get_session)
) -> list[CryptoPortfolioRead]:
return await list_portfolios(session, include_archived)
@router.post("/portfolios", response_model=CryptoPortfolioRead, status_code=201)
async def create_portfolio_route(
payload: CryptoPortfolioCreate, session: AsyncSession = Depends(get_session)
) -> CryptoPortfolioRead:
return await create_portfolio(session, payload)
@router.patch("/portfolios/{portfolio_id}", response_model=CryptoPortfolioRead)
async def update_portfolio_route(
portfolio_id: int, payload: CryptoPortfolioUpdate, session: AsyncSession = Depends(get_session)
) -> CryptoPortfolioRead:
return await update_portfolio(session, portfolio_id, payload)
@router.delete("/portfolios/{portfolio_id}", status_code=204)
async def delete_portfolio_route(portfolio_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_portfolio(session, portfolio_id)
@router.get("/holdings", response_model=CryptoSyncResult) @router.get("/holdings", response_model=CryptoSyncResult)
async def read_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult: async def read_holdings(
portfolio_id: int | None = None, session: AsyncSession = Depends(get_session)
) -> CryptoSyncResult:
"""Opening the Crypto tab lands here — this is also where the lazy """Opening the Crypto tab lands here — this is also where the lazy
once-a-day auto-refresh happens (see services/crypto_service.py): once-a-day auto-refresh happens (see services/crypto_service.py):
prices only actually get re-fetched from CoinGecko if 24h have passed prices only actually get re-fetched from CoinGecko if 24h have passed
since the last sync, otherwise this just reads the current cache.""" since the last sync, otherwise this just reads the current cache."""
return await refresh_prices(session, force=False) return await refresh_prices(session, force=False, portfolio_id=portfolio_id)
@router.post("/refresh", response_model=CryptoSyncResult) @router.post("/refresh", response_model=CryptoSyncResult)
async def refresh_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult: async def refresh_holdings(
portfolio_id: int | None = None, session: AsyncSession = Depends(get_session)
) -> CryptoSyncResult:
"""The "Refresh prices" button — always hits CoinGecko regardless of """The "Refresh prices" button — always hits CoinGecko regardless of
the once-a-day window.""" the once-a-day window."""
return await refresh_prices(session, force=True) return await refresh_prices(session, force=True, portfolio_id=portfolio_id)
@router.post("/holdings", response_model=CryptoHoldingRead, status_code=201) @router.post("/holdings", response_model=CryptoHoldingRead, status_code=201)
@@ -89,6 +126,7 @@ _HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$"
@router.get("/history", response_model=CryptoHistoryResponse) @router.get("/history", response_model=CryptoHistoryResponse)
async def read_crypto_history( async def read_crypto_history(
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN), range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
portfolio_id: int | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> CryptoHistoryResponse: ) -> CryptoHistoryResponse:
return await get_crypto_history(session, range) return await get_crypto_history(session, range, portfolio_id)

View File

@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# title, /api/health (which the frontend reads to show it in Settings), and # title, /api/health (which the frontend reads to show it in Settings), and
# embedded in exported backups so an old file can be told apart from a # embedded in exported backups so an old file can be told apart from a
# current one. # current one.
APP_VERSION = "1.1.1" APP_VERSION = "1.1.2"
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -2,7 +2,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget from app.models.budget import Budget
from app.models.category import Category from app.models.category import Category
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction
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
@@ -17,6 +17,7 @@ __all__ = [
"Budget", "Budget",
"Category", "Category",
"CryptoHolding", "CryptoHolding",
"CryptoPortfolio",
"CryptoSyncState", "CryptoSyncState",
"CryptoTransaction", "CryptoTransaction",
"Goal", "Goal",

View File

@@ -9,11 +9,16 @@ CryptoTransaction log (same "you record it, we derive it" shape as
Goal/GoalContribution), because a sell doesn't just subtract quantity, it Goal/GoalContribution), because a sell doesn't just subtract quantity, it
also has to leave the average cost basis of what's still held unchanged also has to leave the average cost basis of what's still held unchanged
(see services/crypto_service.py's _compute_position). (see services/crypto_service.py's _compute_position).
Every holding also belongs to a CryptoPortfolio (below) — a user-defined
group like "long-term" or "memes" — so the same coin can be tracked
separately in more than one portfolio (each as its own CryptoHolding row,
since coingecko_id is deliberately not unique).
""" """
from datetime import date as date_ from datetime import date as date_
from datetime import datetime from datetime import datetime
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
@@ -22,6 +27,26 @@ from app.models.enums import CryptoTransactionType
from app.models.mixins import TimestampMixin from app.models.mixins import TimestampMixin
class CryptoPortfolio(Base, TimestampMixin):
"""A user-defined grouping of crypto holdings (e.g. "long-term",
"real estate fund", "memes") — same shape as Account (id, name, color,
is_archived), but scoped to the Crypto tab instead of Transactions.
Every CryptoHolding belongs to exactly one portfolio (see
CryptoHolding.portfolio_id below); there's no "ungrouped" state."""
__tablename__ = "crypto_portfolios"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Hex color for the portfolio's tab dot — auto-assigned from the app's
# categorical palette at creation time (see services/crypto_service.py's
# _next_portfolio_color), not user-editable, same as Account.color.
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
holdings: Mapped[list["CryptoHolding"]] = relationship(back_populates="portfolio")
class CryptoHolding(Base, TimestampMixin): class CryptoHolding(Base, TimestampMixin):
"""1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as """1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as
this table's own primary key rather than a separate id+unique this table's own primary key rather than a separate id+unique
@@ -33,6 +58,13 @@ class CryptoHolding(Base, TimestampMixin):
__tablename__ = "crypto_holdings" __tablename__ = "crypto_holdings"
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True) asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True)
# Which portfolio this coin is tracked under (see CryptoPortfolio above).
# RESTRICT, not CASCADE: deleting a non-empty portfolio is blocked at the
# service layer (services/crypto_service.py's delete_portfolio) rather
# than silently taking its holdings down with it.
portfolio_id: Mapped[int] = mapped_column(
ForeignKey("crypto_portfolios.id", ondelete="RESTRICT"), nullable=False
)
# CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol, # CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol,
# which collides across unrelated coins (CoinGecko lists dozens of # which collides across unrelated coins (CoinGecko lists dozens of
@@ -53,6 +85,7 @@ class CryptoHolding(Base, TimestampMixin):
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
asset: Mapped["Asset"] = relationship() asset: Mapped["Asset"] = relationship()
portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings")
transactions: Mapped[list["CryptoTransaction"]] = relationship( transactions: Mapped[list["CryptoTransaction"]] = relationship(
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date" back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
) )

View File

@@ -106,6 +106,15 @@ class AssetValuationBackup(BaseModel):
as_of_date: date_ as_of_date: date_
class CryptoPortfolioBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str | None
is_archived: bool
class CryptoHoldingBackup(BaseModel): class CryptoHoldingBackup(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -114,6 +123,11 @@ class CryptoHoldingBackup(BaseModel):
# quantity here — it's derived from crypto_transactions below, not # quantity here — it's derived from crypto_transactions below, not
# stored. # stored.
asset_id: int asset_id: int
# Defaulted so a backup exported before crypto portfolios existed still
# imports cleanly under the same format version — restore_backup()
# resolves a missing/unknown portfolio_id to an auto-created fallback
# portfolio (see services/backup_service.py).
portfolio_id: int | None = None
coingecko_id: str coingecko_id: str
symbol: str symbol: str
name: str name: str
@@ -217,8 +231,9 @@ class BackupPayload(BaseModel):
transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list) transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list)
assets: list[AssetBackup] assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup] asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before crypto holdings existed still # Defaulted so a backup exported before crypto holdings/portfolios
# imports cleanly under the same format version. # existed still imports cleanly under the same format version.
crypto_portfolios: list[CryptoPortfolioBackup] = Field(default_factory=list)
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list) crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list) crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list)
# Defaulted so a backup exported before budgets existed still imports # Defaulted so a backup exported before budgets existed still imports

View File

@@ -39,7 +39,29 @@ class CryptoTransactionRead(BaseModel):
note: str | None note: str | None
class CryptoPortfolioCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
class CryptoPortfolioUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
is_archived: bool | None = None
class CryptoPortfolioRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str | None
is_archived: bool
class CryptoHoldingCreate(BaseModel): class CryptoHoldingCreate(BaseModel):
# Which portfolio to file this coin under — omit to use the default
# portfolio (the earliest-created one, auto-created if none exists yet;
# see services/crypto_service.py's get_or_create_default_portfolio).
portfolio_id: int | None = None
coingecko_id: str = Field(min_length=1, max_length=100) coingecko_id: str = Field(min_length=1, max_length=100)
symbol: str = Field(min_length=1, max_length=20) symbol: str = Field(min_length=1, max_length=20)
name: str = Field(min_length=1, max_length=150) name: str = Field(min_length=1, max_length=150)
@@ -54,6 +76,7 @@ class CryptoHoldingCreate(BaseModel):
class CryptoHoldingRead(BaseModel): class CryptoHoldingRead(BaseModel):
asset_id: int asset_id: int
portfolio_id: int
coingecko_id: str coingecko_id: str
symbol: str symbol: str
name: str name: str

View File

@@ -21,7 +21,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget from app.models.budget import Budget
from app.models.category import Category from app.models.category import Category
from app.models.crypto import CryptoHolding, CryptoTransaction from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoTransaction
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
@@ -36,6 +36,7 @@ from app.schemas.backup import (
BudgetBackup, BudgetBackup,
CategoryBackup, CategoryBackup,
CryptoHoldingBackup, CryptoHoldingBackup,
CryptoPortfolioBackup,
CryptoTransactionBackup, CryptoTransactionBackup,
GoalBackup, GoalBackup,
GoalContributionBackup, GoalContributionBackup,
@@ -56,6 +57,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all() transaction_splits = (await session.execute(select(TransactionSplit))).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()
crypto_portfolios = (await session.execute(select(CryptoPortfolio))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all() crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all() crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all()
budgets = (await session.execute(select(Budget))).scalars().all() budgets = (await session.execute(select(Budget))).scalars().all()
@@ -80,6 +82,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits], transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
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],
crypto_portfolios=[CryptoPortfolioBackup.model_validate(row) for row in crypto_portfolios],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings], crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions], crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions],
budgets=[BudgetBackup.model_validate(row) for row in budgets], budgets=[BudgetBackup.model_validate(row) for row in budgets],
@@ -124,10 +127,18 @@ def _validate_references(payload: BackupPayload) -> None:
if v.asset_id not in asset_ids: if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}") raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
crypto_portfolio_ids = {p.id for p in payload.crypto_portfolios}
crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings} crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings}
for h in payload.crypto_holdings: for h in payload.crypto_holdings:
if h.asset_id not in asset_ids: if h.asset_id not in asset_ids:
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}") raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
# portfolio_id is allowed to be None (a pre-portfolios backup) — that
# case is resolved to an auto-created fallback portfolio at restore
# time, not validated here.
if h.portfolio_id is not None and h.portfolio_id not in crypto_portfolio_ids:
raise HTTPException(
400, f"Crypto holding {h.asset_id} references unknown portfolio_id {h.portfolio_id}"
)
for tx in payload.crypto_transactions: for tx in payload.crypto_transactions:
if tx.asset_id not in crypto_holding_asset_ids: if tx.asset_id not in crypto_holding_asset_ids:
@@ -182,6 +193,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await session.execute(delete(AssetValuation)) await session.execute(delete(AssetValuation))
await session.execute(delete(CryptoTransaction)) await session.execute(delete(CryptoTransaction))
await session.execute(delete(CryptoHolding)) await session.execute(delete(CryptoHolding))
await session.execute(delete(CryptoPortfolio))
await session.execute(delete(Budget)) await session.execute(delete(Budget))
await session.execute(delete(GoalContribution)) await session.execute(delete(GoalContribution))
await session.execute(delete(Goal)) await session.execute(delete(Goal))
@@ -223,7 +235,30 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits) session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits)
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(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
session.add_all(CryptoPortfolio(**row.model_dump()) for row in payload.crypto_portfolios)
# A pre-portfolios backup has no crypto_portfolios and every holding's
# portfolio_id is None — give those holdings a freshly created
# fallback portfolio instead of leaving the NOT NULL column unset.
# Wired up via the `portfolio` relationship (not a raw portfolio_id
# int) because the fallback row has no id yet — same "assign the
# relationship, not the id column, before flush" reasoning as
# transactions_by_id/tags_by_id above.
fallback_portfolio: CryptoPortfolio | None = None
if any(row.portfolio_id is None for row in payload.crypto_holdings):
fallback_portfolio = CryptoPortfolio(name="Main Portfolio", color="#2a78d6")
session.add(fallback_portfolio)
crypto_holdings = []
for row in payload.crypto_holdings:
holding = CryptoHolding(**row.model_dump(exclude={"portfolio_id"}))
if row.portfolio_id is not None:
holding.portfolio_id = row.portfolio_id
else:
holding.portfolio = fallback_portfolio
crypto_holdings.append(holding)
session.add_all(crypto_holdings)
session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions) session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions)
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)
@@ -244,6 +279,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await _reset_sequence(session, "transactions", payload.transactions) await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "transaction_splits", payload.transaction_splits) await _reset_sequence(session, "transaction_splits", payload.transaction_splits)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations) await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
# Only needed for explicit-id portfolios from payload — a fallback
# portfolio (no payload row) already got its id from the sequence
# itself, so the sequence is already correctly positioned for it.
await _reset_sequence(session, "crypto_portfolios", payload.crypto_portfolios)
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions) await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
await _reset_sequence(session, "budgets", payload.budgets) await _reset_sequence(session, "budgets", payload.budgets)
await _reset_sequence(session, "goals", payload.goals) await _reset_sequence(session, "goals", payload.goals)

View File

@@ -34,13 +34,16 @@ from sqlalchemy.orm import selectinload
from app.core.config import get_settings from app.core.config import get_settings
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.schemas.crypto import ( from app.schemas.crypto import (
CryptoHistoryPoint, CryptoHistoryPoint,
CryptoHistoryResponse, CryptoHistoryResponse,
CryptoHoldingCreate, CryptoHoldingCreate,
CryptoHoldingRead, CryptoHoldingRead,
CryptoPortfolioCreate,
CryptoPortfolioRead,
CryptoPortfolioUpdate,
CryptoSearchResult, CryptoSearchResult,
CryptoSyncResult, CryptoSyncResult,
CryptoTransactionCreate, CryptoTransactionCreate,
@@ -48,6 +51,21 @@ from app.schemas.crypto import (
) )
from app.services.settings_service import get_or_create_app_settings from app.services.settings_service import get_or_create_app_settings
# Same 8-hue, colorblind-safe categorical set app/db/seed.py assigns default
# categories from — reused here (cycling by creation order) so portfolio tab
# dots are visually distinct out of the box, with no manual color picker to
# build for a v1 feature.
PORTFOLIO_PALETTE = [
"#2a78d6",
"#eb6834",
"#1baf7a",
"#eda100",
"#e87ba4",
"#008300",
"#4a3aa7",
"#e34948",
]
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3" COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
# CoinGecko's own Demo-tier data only refreshes every 60s server-side # CoinGecko's own Demo-tier data only refreshes every 60s server-side
# (see docs.coingecko.com/reference/simple-price) — polling more often than # (see docs.coingecko.com/reference/simple-price) — polling more often than
@@ -179,6 +197,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None
return CryptoHoldingRead( return CryptoHoldingRead(
asset_id=holding.asset_id, asset_id=holding.asset_id,
portfolio_id=holding.portfolio_id,
coingecko_id=holding.coingecko_id, coingecko_id=holding.coingecko_id,
symbol=holding.symbol, symbol=holding.symbol,
name=holding.name, name=holding.name,
@@ -196,8 +215,77 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
) )
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]: def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead:
result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name)) return CryptoPortfolioRead.model_validate(portfolio)
async def list_portfolios(session: AsyncSession, include_archived: bool) -> list[CryptoPortfolioRead]:
stmt = select(CryptoPortfolio).order_by(CryptoPortfolio.id)
if not include_archived:
stmt = stmt.where(CryptoPortfolio.is_archived.is_(False))
portfolios = (await session.execute(stmt)).scalars().all()
return [_portfolio_to_read(p) for p in portfolios]
async def get_or_create_default_portfolio(session: AsyncSession) -> CryptoPortfolio:
"""The portfolio a new holding lands in when the caller doesn't specify
one (see CryptoHoldingCreate.portfolio_id) — the earliest-created
portfolio, auto-created the first time it's needed. Same self-healing
"create on first use" shape as seed_default_app_settings: a portfolio can
later be deleted once empty (see delete_portfolio), so this can't just
assume row id=1 always exists."""
existing = (
await session.execute(select(CryptoPortfolio).order_by(CryptoPortfolio.id).limit(1))
).scalar_one_or_none()
if existing is not None:
return existing
portfolio = CryptoPortfolio(name="Main Portfolio", color=PORTFOLIO_PALETTE[0])
session.add(portfolio)
await session.flush()
return portfolio
async def create_portfolio(session: AsyncSession, payload: CryptoPortfolioCreate) -> CryptoPortfolioRead:
count = (await session.execute(select(CryptoPortfolio.id))).scalars().all()
color = PORTFOLIO_PALETTE[len(count) % len(PORTFOLIO_PALETTE)]
portfolio = CryptoPortfolio(name=payload.name, color=color)
session.add(portfolio)
await session.commit()
await session.refresh(portfolio)
return _portfolio_to_read(portfolio)
async def update_portfolio(
session: AsyncSession, portfolio_id: int, payload: CryptoPortfolioUpdate
) -> CryptoPortfolioRead:
portfolio = await session.get(CryptoPortfolio, portfolio_id)
if portfolio is None:
raise HTTPException(status_code=404, detail="Crypto portfolio not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(portfolio, field, value)
await session.commit()
await session.refresh(portfolio)
return _portfolio_to_read(portfolio)
async def delete_portfolio(session: AsyncSession, portfolio_id: int) -> None:
portfolio = await session.get(CryptoPortfolio, portfolio_id)
if portfolio is None:
raise HTTPException(status_code=404, detail="Crypto portfolio not found")
has_holding = (
await session.execute(select(CryptoHolding.asset_id).where(CryptoHolding.portfolio_id == portfolio_id).limit(1))
).first()
if has_holding is not None:
raise HTTPException(status_code=400, detail="Move or delete its coins before deleting a portfolio")
await session.delete(portfolio)
await session.commit()
async def list_holdings(session: AsyncSession, portfolio_id: int | None = None) -> list[CryptoHolding]:
stmt = select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name)
if portfolio_id is not None:
stmt = stmt.where(CryptoHolding.portfolio_id == portfolio_id)
result = await session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
@@ -236,12 +324,17 @@ async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal
await session.execute(upsert_stmt) await session.execute(upsert_stmt)
async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncResult: async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: int | None = None) -> CryptoSyncResult:
"""Sync always covers every holding regardless of `portfolio_id` — a
stale price on a coin the user isn't currently looking at would still be
wrong the next time they switch tabs. `portfolio_id` only narrows what's
returned in the response's `holdings` list, for the Crypto tab's
portfolio filter."""
state = await get_or_create_sync_state(session) state = await get_or_create_sync_state(session)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL: if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL:
holdings = await list_holdings(session) holdings = await list_holdings(session, portfolio_id)
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings]) return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings])
holdings = await list_holdings(session) holdings = await list_holdings(session)
@@ -277,17 +370,26 @@ async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncRes
# holdings were mutated in place above (ordinary ORM attribute # holdings were mutated in place above (ordinary ORM attribute
# assignment, not a raw Core write) — no need to re-query, they already # assignment, not a raw Core write) — no need to re-query, they already
# reflect what was just committed. # reflect what was just committed.
visible = holdings if portfolio_id is None else [h for h in holdings if h.portfolio_id == portfolio_id]
return CryptoSyncResult( return CryptoSyncResult(
synced=error_key is None, synced=error_key is None,
last_synced_at=state.last_synced_at, last_synced_at=state.last_synced_at,
error_key=error_key, error_key=error_key,
holdings=[_to_read(h) for h in holdings], holdings=[_to_read(h) for h in visible],
) )
async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead: async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead:
settings = await get_or_create_app_settings(session) settings = await get_or_create_app_settings(session)
if payload.portfolio_id is not None:
portfolio = await session.get(CryptoPortfolio, payload.portfolio_id)
if portfolio is None:
raise HTTPException(status_code=400, detail="Crypto portfolio not found")
portfolio_id = portfolio.id
else:
portfolio_id = (await get_or_create_default_portfolio(session)).id
asset = Asset( asset = Asset(
name=payload.name, name=payload.name,
asset_class=AssetClass.CRYPTO, asset_class=AssetClass.CRYPTO,
@@ -303,6 +405,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
holding = CryptoHolding( holding = CryptoHolding(
asset_id=asset.id, asset_id=asset.id,
portfolio_id=portfolio_id,
coingecko_id=payload.coingecko_id, coingecko_id=payload.coingecko_id,
symbol=payload.symbol.upper(), symbol=payload.symbol.upper(),
name=payload.name, name=payload.name,
@@ -440,19 +543,28 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None
await session.commit() await session.commit()
async def get_crypto_history(session: AsyncSession, range_key: str) -> CryptoHistoryResponse: async def get_crypto_history(
session: AsyncSession, range_key: str, portfolio_id: int | None = None
) -> CryptoHistoryResponse:
"""Total crypto holdings value over time, for the History chart on the """Total crypto holdings value over time, for the History chart on the
Crypto tab. Same "cumulative point events, forward-filled" approach as Crypto tab. Same "cumulative point events, forward-filled" approach as
net_worth_service.py's own asset series — duplicated here rather than net_worth_service.py's own asset series — duplicated here rather than
imported, so this can't destabilize the already-tested Net Worth engine, imported, so this can't destabilize the already-tested Net Worth engine,
and scoped to crypto-class assets only (a deleted holding's and scoped to crypto-class assets only (a deleted holding's
AssetValuation rows are gone via cascade, so its history naturally AssetValuation rows are gone via cascade, so its history naturally
drops out here too, same as it already does for Net Worth).""" drops out here too, same as it already does for Net Worth). Further
scoped to one portfolio's assets when `portfolio_id` is given, for the
Crypto tab's portfolio filter."""
today = date_.today() today = date_.today()
crypto_asset_ids = set( asset_stmt = (
(await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all() select(Asset.id)
.join(CryptoHolding, CryptoHolding.asset_id == Asset.id)
.where(Asset.asset_class == AssetClass.CRYPTO)
) )
if portfolio_id is not None:
asset_stmt = asset_stmt.where(CryptoHolding.portfolio_id == portfolio_id)
crypto_asset_ids = set((await session.execute(asset_stmt)).scalars().all())
if not crypto_asset_ids: if not crypto_asset_ids:
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[]) return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])

View File

@@ -273,6 +273,59 @@ async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: As
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000) assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
async def test_backup_roundtrip_preserves_portfolio_assignment(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
portfolio = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json()
resp = await client.post(
"/crypto/holdings",
json={
"portfolio_id": portfolio["id"],
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"quantity": "1",
"price_per_unit": "40000",
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
payload = (await client.get("/backup/export")).json()
assert len(payload["crypto_portfolios"]) == 1
assert payload["crypto_holdings"][0]["portfolio_id"] == portfolio["id"]
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
restored_portfolios = (await client.get("/crypto/portfolios")).json()
assert len(restored_portfolios) == 1
assert restored_portfolios[0]["name"] == "Long-term"
restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0]
assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"]
async def test_restoring_a_pre_portfolios_backup_falls_back_to_a_default_portfolio(client: AsyncClient, monkeypatch):
"""A backup exported before crypto portfolios existed has no
crypto_portfolios list and every holding's portfolio_id is absent —
restore_backup() must still produce a valid NOT NULL portfolio_id
instead of failing the import."""
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
holding = await _add_bitcoin(client, "1", "40000")
payload = (await client.get("/backup/export")).json()
payload["crypto_portfolios"] = []
payload["crypto_holdings"][0]["portfolio_id"] = None
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
restored_portfolios = (await client.get("/crypto/portfolios")).json()
assert len(restored_portfolios) == 1
restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0]
assert restored_holding["asset_id"] == holding["asset_id"]
assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"]
async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch): async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
await _add_bitcoin(client, "1", "40000") await _add_bitcoin(client, "1", "40000")
@@ -358,3 +411,105 @@ async def test_update_transaction_404_for_unknown_id(client: AsyncClient):
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"}) resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
assert resp.status_code == 404 assert resp.status_code == 404
async def test_holding_without_portfolio_id_lands_in_an_auto_created_default(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
holding = await _add_bitcoin(client)
portfolios = (await client.get("/crypto/portfolios")).json()
assert len(portfolios) == 1
assert portfolios[0]["name"] == "Main Portfolio"
assert holding["portfolio_id"] == portfolios[0]["id"]
async def test_create_portfolio_and_file_a_holding_under_it(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
created = await client.post("/crypto/portfolios", json={"name": "Long-term"})
assert created.status_code == 201, created.text
portfolio = created.json()
assert portfolio["name"] == "Long-term"
assert portfolio["is_archived"] is False
resp = await client.post(
"/crypto/holdings",
json={
"portfolio_id": portfolio["id"],
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"quantity": "0.5",
"price_per_unit": "40000",
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
assert resp.json()["portfolio_id"] == portfolio["id"]
async def test_same_coin_can_be_tracked_separately_in_two_portfolios(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
memes = (await client.post("/crypto/portfolios", json={"name": "Memes"})).json()
longterm = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json()
async def add(portfolio_id, quantity):
resp = await client.post(
"/crypto/holdings",
json={
"portfolio_id": portfolio_id,
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"quantity": quantity,
"price_per_unit": "40000",
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
return resp.json()
await add(memes["id"], "1")
await add(longterm["id"], "2")
all_holdings = (await client.get("/crypto/holdings")).json()["holdings"]
assert len(all_holdings) == 2
memes_holdings = (await client.get(f"/crypto/holdings?portfolio_id={memes['id']}")).json()["holdings"]
assert len(memes_holdings) == 1
assert money(memes_holdings[0]["quantity"]) == money("1")
longterm_history = (await client.get(f"/crypto/history?range=30d&portfolio_id={longterm['id']}")).json()
assert money(longterm_history["current"]) == money("100000") # 2 * 50000, memes' 1 BTC excluded
async def test_deleting_a_nonempty_portfolio_is_rejected(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
holding = await _add_bitcoin(client)
portfolio_id = holding["portfolio_id"]
resp = await client.delete(f"/crypto/portfolios/{portfolio_id}")
assert resp.status_code == 400
async def test_archived_portfolios_are_excluded_by_default(client: AsyncClient):
portfolio = (await client.post("/crypto/portfolios", json={"name": "Old fund"})).json()
await client.patch(f"/crypto/portfolios/{portfolio['id']}", json={"is_archived": True})
visible = (await client.get("/crypto/portfolios")).json()
assert visible == []
with_archived = (await client.get("/crypto/portfolios?include_archived=true")).json()
assert len(with_archived) == 1
assert with_archived[0]["is_archived"] is True
async def test_empty_archived_portfolio_can_be_deleted(client: AsyncClient):
portfolio = (await client.post("/crypto/portfolios", json={"name": "Unused"})).json()
resp = await client.delete(f"/crypto/portfolios/{portfolio['id']}")
assert resp.status_code == 204
assert (await client.get("/crypto/portfolios")).json() == []

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "1.1.1", "version": "1.1.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -3,6 +3,8 @@ import type {
CryptoHistoryResponse, CryptoHistoryResponse,
CryptoHolding, CryptoHolding,
CryptoHoldingCreateInput, CryptoHoldingCreateInput,
CryptoPortfolio,
CryptoPortfolioInput,
CryptoRange, CryptoRange,
CryptoSearchResult, CryptoSearchResult,
CryptoSyncResult, CryptoSyncResult,
@@ -10,14 +12,34 @@ import type {
CryptoTransactionInput, CryptoTransactionInput,
} from "@/types"; } from "@/types";
// Also triggers the lazy once-a-day auto-refresh server-side — see export function fetchCryptoPortfolios(includeArchived = false) {
// services/crypto_service.py. return api.get<CryptoPortfolio[]>(`/crypto/portfolios?include_archived=${includeArchived}`);
export function fetchCryptoHoldings() {
return api.get<CryptoSyncResult>("/crypto/holdings");
} }
export function refreshCryptoPrices() { export function createCryptoPortfolio(input: CryptoPortfolioInput) {
return api.post<CryptoSyncResult>("/crypto/refresh", {}); return api.post<CryptoPortfolio>("/crypto/portfolios", input);
}
export function updateCryptoPortfolio(portfolioId: number, input: Partial<CryptoPortfolioInput>) {
return api.patch<CryptoPortfolio>(`/crypto/portfolios/${portfolioId}`, input);
}
export function deleteCryptoPortfolio(portfolioId: number) {
return api.delete<void>(`/crypto/portfolios/${portfolioId}`);
}
// Also triggers the lazy once-a-day auto-refresh server-side — see
// services/crypto_service.py. `portfolioId` only narrows what comes back in
// `holdings` — the sync itself (and the 24h window) always covers every
// portfolio, see refresh_prices' own docstring.
export function fetchCryptoHoldings(portfolioId?: number | null) {
const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : "";
return api.get<CryptoSyncResult>(`/crypto/holdings${query}`);
}
export function refreshCryptoPrices(portfolioId?: number | null) {
const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : "";
return api.post<CryptoSyncResult>(`/crypto/refresh${query}`, {});
} }
export function createCryptoHolding(input: CryptoHoldingCreateInput) { export function createCryptoHolding(input: CryptoHoldingCreateInput) {
@@ -52,6 +74,7 @@ export function searchCryptoCoins(query: string) {
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`); return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
} }
export function fetchCryptoHistory(range: CryptoRange) { export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | null) {
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}`); const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : "";
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}${query}`);
} }

View File

@@ -3,28 +3,34 @@ import { ApiError } from "@/api/client";
import { searchCryptoCoins } from "@/api/crypto"; import { searchCryptoCoins } from "@/api/crypto";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog"; import { Dialog } from "@/components/ui/Dialog";
import { Input, Label } from "@/components/ui/Input"; import { Input, Label, Select } from "@/components/ui/Input";
import { useCreateCryptoHolding } from "@/hooks/useCrypto"; import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoSearchResult } from "@/types"; import type { CryptoSearchResult } from "@/types";
interface CryptoAddModalProps { interface CryptoAddModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
// The portfolio tab active on the Crypto page when "+" was clicked — pre-
// selects that portfolio here instead of always defaulting to the first
// one. Null (the "All" tab) falls back to the first portfolio in the list.
defaultPortfolioId?: number | null;
} }
/** Two phases in one dialog: search CoinGecko for a coin, then pick one and /** Two phases in one dialog: search CoinGecko for a coin, then pick one and
* enter how much of it is held. No separate multi-step wizard — unlike CSV * enter how much of it is held. No separate multi-step wizard — unlike CSV
* import, there's nothing here worth a dedicated page for. */ * import, there's nothing here worth a dedicated page for. */
export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) { export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const createHolding = useCreateCryptoHolding(); const createHolding = useCreateCryptoHolding();
const { data: portfolios } = useCryptoPortfolios();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<CryptoSearchResult[]>([]); const [results, setResults] = useState<CryptoSearchResult[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null); const [searchError, setSearchError] = useState<string | null>(null);
const [selected, setSelected] = useState<CryptoSearchResult | null>(null); const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
const [portfolioId, setPortfolioId] = useState<number | null>(null);
const [quantity, setQuantity] = useState(""); const [quantity, setQuantity] = useState("");
const [pricePerUnit, setPricePerUnit] = useState(""); const [pricePerUnit, setPricePerUnit] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
@@ -42,6 +48,18 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
setSaveError(null); setSaveError(null);
}, [open]); }, [open]);
// Re-picked every time the dialog opens or the portfolio list loads —
// covers both "opened from a specific tab" and "opened from All before
// any portfolio existed yet, then the default one got created".
useEffect(() => {
if (!open) return;
if (defaultPortfolioId != null) {
setPortfolioId(defaultPortfolioId);
} else if (portfolios && portfolios.length > 0) {
setPortfolioId((current) => (current !== null && portfolios.some((p) => p.id === current) ? current : portfolios[0].id));
}
}, [open, defaultPortfolioId, portfolios]);
useEffect(() => { useEffect(() => {
if (!open || selected) return; if (!open || selected) return;
const trimmed = query.trim(); const trimmed = query.trim();
@@ -73,6 +91,7 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
setSaveError(null); setSaveError(null);
try { try {
await createHolding.mutateAsync({ await createHolding.mutateAsync({
portfolio_id: portfolioId,
coingecko_id: selected.coingecko_id, coingecko_id: selected.coingecko_id,
symbol: selected.symbol, symbol: selected.symbol,
name: selected.name, name: selected.name,
@@ -145,6 +164,23 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
</span> </span>
</div> </div>
{portfolios && portfolios.length > 1 && (
<div>
<Label htmlFor="crypto-portfolio">{t("crypto.portfolio.form.nameLabel")}</Label>
<Select
id="crypto-portfolio"
value={portfolioId ?? ""}
onChange={(event) => setPortfolioId(Number(event.target.value))}
>
{portfolios.map((portfolio) => (
<option key={portfolio.id} value={portfolio.id}>
{portfolio.name}
</option>
))}
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label> <Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>

View File

@@ -1,7 +1,7 @@
import { Bitcoin, Plus, Trash2 } from "lucide-react"; import { Bitcoin, Plus, Trash2 } from "lucide-react";
import { formatCurrency, maskAmount } from "@/lib/format"; import { formatCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types"; import type { CryptoHolding, CryptoPortfolio } from "@/types";
interface CryptoHoldingsTableProps { interface CryptoHoldingsTableProps {
items: CryptoHolding[]; items: CryptoHolding[];
@@ -9,6 +9,11 @@ interface CryptoHoldingsTableProps {
onTrade: (holding: CryptoHolding) => void; onTrade: (holding: CryptoHolding) => void;
onViewHistory: (holding: CryptoHolding) => void; onViewHistory: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void; onDelete: (holding: CryptoHolding) => void;
// Only passed while viewing the "All" tab — renders each row's portfolio
// as a small colored badge under the coin name so it's still clear which
// portfolio it belongs to. Omitted while a single portfolio is selected,
// since every row would carry the same badge.
portfoliosById?: Map<number, CryptoPortfolio>;
} }
function PercentCell({ value }: { value: string | null }) { function PercentCell({ value }: { value: string | null }) {
@@ -23,7 +28,14 @@ function PercentCell({ value }: { value: string | null }) {
); );
} }
export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) { export function CryptoHoldingsTable({
items,
hidden,
onTrade,
onViewHistory,
onDelete,
portfoliosById,
}: CryptoHoldingsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (items.length === 0) { if (items.length === 0) {
@@ -75,7 +87,20 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD
)} )}
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span> <span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
<span className="block text-xs text-text-muted">{holding.symbol}</span> <span className="flex items-center gap-1.5 text-xs text-text-muted">
{holding.symbol}
{portfoliosById && (
<span className="flex items-center gap-1 truncate">
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{
backgroundColor: portfoliosById.get(holding.portfolio_id)?.color ?? "var(--text-muted)",
}}
/>
<span className="truncate">{portfoliosById.get(holding.portfolio_id)?.name}</span>
</span>
)}
</span>
</span> </span>
</div> </div>
</td> </td>

View File

@@ -0,0 +1,124 @@
import { type FormEvent, useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog";
import { Input, Label } from "@/components/ui/Input";
import { useCreateCryptoPortfolio, useDeleteCryptoPortfolio, useUpdateCryptoPortfolio } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n";
import type { CryptoPortfolio } from "@/types";
interface CryptoPortfolioFormModalProps {
open: boolean;
onClose: () => void;
portfolio?: CryptoPortfolio | null;
// Called after a successful delete — lets CryptoPage fall back to the
// "All" tab when the portfolio it was showing just disappeared.
onDeleted?: () => void;
}
/** Create/rename a portfolio, plus (when editing) archive and delete —
* folded into one dialog rather than a separate management page, since a
* portfolio is a lightweight grouping with only a name to edit. */
export function CryptoPortfolioFormModal({ open, onClose, portfolio, onDeleted }: CryptoPortfolioFormModalProps) {
const { t } = useTranslation();
const createPortfolio = useCreateCryptoPortfolio();
const updatePortfolio = useUpdateCryptoPortfolio();
const deletePortfolio = useDeleteCryptoPortfolio();
const [name, setName] = useState("");
const [saveError, setSaveError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(portfolio?.name ?? "");
setSaveError(null);
setDeleteError(null);
}, [open, portfolio]);
const isSaving = createPortfolio.isPending || updatePortfolio.isPending;
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setSaveError(null);
try {
if (portfolio) {
await updatePortfolio.mutateAsync({ id: portfolio.id, input: { name } });
} else {
await createPortfolio.mutateAsync({ name });
}
onClose();
} catch {
setSaveError(t("crypto.portfolio.form.saveError"));
}
}
function handleToggleArchived() {
if (!portfolio) return;
updatePortfolio.mutate({ id: portfolio.id, input: { is_archived: !portfolio.is_archived } });
}
async function handleDelete() {
if (!portfolio) return;
if (!window.confirm(t("crypto.portfolio.confirmDelete", { name: portfolio.name }))) return;
setDeleteError(null);
try {
await deletePortfolio.mutateAsync(portfolio.id);
onDeleted?.();
onClose();
} catch {
// 400 when the portfolio still has coins in it — the only realistic
// failure mode here (see services/crypto_service.py's delete_portfolio).
setDeleteError(t("crypto.portfolio.deleteError"));
}
}
return (
<Dialog
open={open}
onClose={onClose}
title={portfolio ? t("crypto.portfolio.form.editTitle") : t("crypto.portfolio.form.newTitle")}
>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<Label htmlFor="portfolio-name">{t("crypto.portfolio.form.nameLabel")}</Label>
<Input
id="portfolio-name"
autoFocus
required
maxLength={100}
placeholder={t("crypto.portfolio.form.namePlaceholder")}
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
{saveError && <p className="text-sm text-danger">{saveError}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? t("common.saving") : t("common.save")}
</Button>
</div>
{portfolio && (
<div className="flex items-center justify-between border-t border-border pt-3">
<button
type="button"
onClick={handleToggleArchived}
className="text-xs font-medium text-text-muted hover:text-text-primary"
>
{portfolio.is_archived ? t("crypto.portfolio.unarchiveLabel") : t("crypto.portfolio.archiveLabel")}
</button>
<button type="button" onClick={handleDelete} className="text-xs font-medium text-danger hover:underline">
{t("common.delete")}
</button>
</div>
)}
{deleteError && <p className="text-sm text-danger">{deleteError}</p>}
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
import { Pencil, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import type { CryptoPortfolio } from "@/types";
interface CryptoPortfolioTabsProps {
portfolios: CryptoPortfolio[];
activeId: number | null;
onChange: (id: number | null) => void;
onAdd: () => void;
onEdit: (portfolio: CryptoPortfolio) => void;
}
/** Horizontally-scrolling pill row (no wrap) so a long list of portfolios
* stays usable on a phone screen instead of pushing the rest of the tab
* down. "All" plus one pill per portfolio, a pencil to edit whichever one
* is active, and a dashed "+" pill to create another. */
export function CryptoPortfolioTabs({ portfolios, activeId, onChange, onAdd, onEdit }: CryptoPortfolioTabsProps) {
const { t } = useTranslation();
const active = portfolios.find((p) => p.id === activeId) ?? null;
return (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1">
<button
type="button"
onClick={() => onChange(null)}
className={cn(
"shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
activeId === null
? "border-series-1 bg-surface-2 text-text-primary"
: "border-border text-text-muted hover:text-text-primary"
)}
>
{t("crypto.portfolio.all")}
</button>
{portfolios.map((portfolio) => {
const isActive = portfolio.id === activeId;
return (
<button
key={portfolio.id}
type="button"
onClick={() => onChange(portfolio.id)}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
isActive
? "border-series-1 bg-surface-2 text-text-primary"
: "border-border text-text-muted hover:text-text-primary"
)}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: portfolio.color ?? "var(--text-muted)" }}
/>
{portfolio.name}
</button>
);
})}
{active && (
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(active)}
className="shrink-0 rounded-full p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={13} />
</button>
)}
<button
type="button"
aria-label={t("crypto.portfolio.addLabel")}
onClick={onAdd}
className="shrink-0 rounded-full border border-dashed border-border p-1.5 text-text-muted hover:border-series-1 hover:text-text-primary"
>
<Plus size={13} />
</button>
</div>
);
}

View File

@@ -2,15 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
addCryptoTransaction, addCryptoTransaction,
createCryptoHolding, createCryptoHolding,
createCryptoPortfolio,
deleteCryptoHolding, deleteCryptoHolding,
deleteCryptoPortfolio,
deleteCryptoTransaction, deleteCryptoTransaction,
fetchCryptoHistory, fetchCryptoHistory,
fetchCryptoHoldings, fetchCryptoHoldings,
fetchCryptoPortfolios,
fetchCryptoTransactions, fetchCryptoTransactions,
refreshCryptoPrices, refreshCryptoPrices,
updateCryptoPortfolio,
updateCryptoTransaction, updateCryptoTransaction,
} from "@/api/crypto"; } from "@/api/crypto";
import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types"; import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput } from "@/types";
function useInvalidateCrypto() { function useInvalidateCrypto() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -24,12 +28,50 @@ function useInvalidateCrypto() {
}; };
} }
export function useCryptoHoldings() { export function useCryptoPortfolios(includeArchived = false) {
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings }); return useQuery({
queryKey: ["crypto-portfolios", includeArchived],
queryFn: () => fetchCryptoPortfolios(includeArchived),
});
} }
export function useCryptoHistory(range: CryptoRange) { export function useCreateCryptoPortfolio() {
return useQuery({ queryKey: ["crypto-history", range], queryFn: () => fetchCryptoHistory(range) }); const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CryptoPortfolioInput) => createCryptoPortfolio(input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }),
});
}
export function useUpdateCryptoPortfolio() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: Partial<CryptoPortfolioInput> }) =>
updateCryptoPortfolio(id, input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }),
});
}
export function useDeleteCryptoPortfolio() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteCryptoPortfolio(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["crypto-portfolios"] }),
});
}
export function useCryptoHoldings(portfolioId?: number | null) {
return useQuery({
queryKey: ["crypto-holdings", portfolioId ?? null],
queryFn: () => fetchCryptoHoldings(portfolioId),
});
}
export function useCryptoHistory(range: CryptoRange, portfolioId?: number | null) {
return useQuery({
queryKey: ["crypto-history", range, portfolioId ?? null],
queryFn: () => fetchCryptoHistory(range, portfolioId),
});
} }
export function useCryptoTransactions(assetId: number | null) { export function useCryptoTransactions(assetId: number | null) {
@@ -42,7 +84,10 @@ export function useCryptoTransactions(assetId: number | null) {
export function useRefreshCryptoPrices() { export function useRefreshCryptoPrices() {
const invalidate = useInvalidateCrypto(); const invalidate = useInvalidateCrypto();
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate }); // Always a bare call — sync covers every portfolio regardless of which
// tab is active (see refresh_prices' own docstring), so there's no
// portfolio_id variable for callers to pass here.
return useMutation({ mutationFn: () => refreshCryptoPrices(), onSuccess: invalidate });
} }
export function useCreateCryptoHolding() { export function useCreateCryptoHolding() {

View File

@@ -162,6 +162,17 @@ const ru = {
"crypto.stats.bestPerformer": "Лучший рост", "crypto.stats.bestPerformer": "Лучший рост",
"crypto.stats.worstPerformer": "Худшее падение", "crypto.stats.worstPerformer": "Худшее падение",
"crypto.stats.change24h": "изменение цены за 24ч", "crypto.stats.change24h": "изменение цены за 24ч",
"crypto.portfolio.all": "Все портфели",
"crypto.portfolio.addLabel": "Добавить портфель",
"crypto.portfolio.archiveLabel": "Архивировать портфель",
"crypto.portfolio.unarchiveLabel": "Вернуть из архива",
"crypto.portfolio.confirmDelete": "Удалить портфель «{{name}}»?",
"crypto.portfolio.deleteError": "Сначала переместите или удалите монеты из этого портфеля.",
"crypto.portfolio.form.newTitle": "Новый портфель",
"crypto.portfolio.form.editTitle": "Портфель",
"crypto.portfolio.form.nameLabel": "Название портфеля",
"crypto.portfolio.form.namePlaceholder": "Например, «Долгосрочные инвестиции»",
"crypto.portfolio.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
"roi.calculator.title": "Калькулятор ROI", "roi.calculator.title": "Калькулятор ROI",
"roi.calculator.investmentLabel": "Сумма инвестиции", "roi.calculator.investmentLabel": "Сумма инвестиции",
@@ -645,6 +656,17 @@ const en: Record<keyof typeof ru, string> = {
"crypto.stats.bestPerformer": "Best Performer", "crypto.stats.bestPerformer": "Best Performer",
"crypto.stats.worstPerformer": "Worst Performer", "crypto.stats.worstPerformer": "Worst Performer",
"crypto.stats.change24h": "24h price change", "crypto.stats.change24h": "24h price change",
"crypto.portfolio.all": "All portfolios",
"crypto.portfolio.addLabel": "Add portfolio",
"crypto.portfolio.archiveLabel": "Archive portfolio",
"crypto.portfolio.unarchiveLabel": "Unarchive",
"crypto.portfolio.confirmDelete": "Delete portfolio «{{name}}»?",
"crypto.portfolio.deleteError": "Move or delete its coins before deleting a portfolio.",
"crypto.portfolio.form.newTitle": "New portfolio",
"crypto.portfolio.form.editTitle": "Portfolio",
"crypto.portfolio.form.nameLabel": "Portfolio name",
"crypto.portfolio.form.namePlaceholder": "e.g. \"Long-term investments\"",
"crypto.portfolio.form.saveError": "Couldn't save. Check the details and try again.",
"roi.calculator.title": "ROI Calculator", "roi.calculator.title": "ROI Calculator",
"roi.calculator.investmentLabel": "Investment amount", "roi.calculator.investmentLabel": "Investment amount",

View File

@@ -1,17 +1,25 @@
import { useState } from "react"; import { useMemo, useState } from "react";
import { Plus, RefreshCw } from "lucide-react"; import { Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal"; import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable"; import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
import { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard"; import { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard";
import { CryptoPortfolioFormModal } from "@/components/crypto/CryptoPortfolioFormModal";
import { CryptoPortfolioTabs } from "@/components/crypto/CryptoPortfolioTabs";
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow"; import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal"; import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal"; import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto"; import {
useCryptoHistory,
useCryptoHoldings,
useCryptoPortfolios,
useDeleteCryptoHolding,
useRefreshCryptoPrices,
} from "@/hooks/useCrypto";
import { getIntlLocale } from "@/lib/format"; import { getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding, CryptoRange, CryptoTransaction } from "@/types"; import type { CryptoHolding, CryptoPortfolio, CryptoRange, CryptoTransaction } from "@/types";
function formatSyncedAt(iso: string): string { function formatSyncedAt(iso: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso)); return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
@@ -25,12 +33,17 @@ export function CryptoPage() {
// history) masks together instead of the toggle only affecting whichever // history) masks together instead of the toggle only affecting whichever
// component happened to own it. // component happened to own it.
const [hidden, setHidden] = useState(false); const [hidden, setHidden] = useState(false);
const { data, isLoading } = useCryptoHoldings(); // null = "All portfolios" tab.
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range); const [portfolioFilter, setPortfolioFilter] = useState<number | null>(null);
const { data: portfolios } = useCryptoPortfolios();
const { data, isLoading } = useCryptoHoldings(portfolioFilter);
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter);
const refresh = useRefreshCryptoPrices(); const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding(); const deleteHolding = useDeleteCryptoHolding();
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null);
const [portfolioModalOpen, setPortfolioModalOpen] = useState(false);
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null); const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null); const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
// Set together with tradingHolding when editing an entry from the history // Set together with tradingHolding when editing an entry from the history
@@ -41,6 +54,17 @@ export function CryptoPage() {
const holdings = data?.holdings ?? []; const holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0); const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
const portfoliosById = useMemo(() => new Map((portfolios ?? []).map((p) => [p.id, p])), [portfolios]);
function openAddPortfolioModal() {
setEditingPortfolio(null);
setPortfolioModalOpen(true);
}
function openEditPortfolioModal(portfolio: CryptoPortfolio) {
setEditingPortfolio(portfolio);
setPortfolioModalOpen(true);
}
function handleDelete(holding: CryptoHolding) { function handleDelete(holding: CryptoHolding) {
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) { if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
@@ -66,6 +90,14 @@ export function CryptoPage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<CryptoPortfolioTabs
portfolios={portfolios ?? []}
activeId={portfolioFilter}
onChange={setPortfolioFilter}
onAdd={openAddPortfolioModal}
onEdit={openEditPortfolioModal}
/>
<CryptoOverviewCard <CryptoOverviewCard
totalValue={totalValue} totalValue={totalValue}
history={history} history={history}
@@ -113,13 +145,22 @@ export function CryptoPage() {
onTrade={openTradeModal} onTrade={openTradeModal}
onViewHistory={setHistoryHolding} onViewHistory={setHistoryHolding}
onDelete={handleDelete} onDelete={handleDelete}
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
/> />
</> </>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} /> <CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} defaultPortfolioId={portfolioFilter} />
<CryptoPortfolioFormModal
open={portfolioModalOpen}
onClose={() => setPortfolioModalOpen(false)}
portfolio={editingPortfolio}
onDeleted={() => {
if (editingPortfolio && portfolioFilter === editingPortfolio.id) setPortfolioFilter(null);
}}
/>
<CryptoTransactionModal <CryptoTransactionModal
open={tradingHolding !== null} open={tradingHolding !== null}
onClose={closeTradeModal} onClose={closeTradeModal}

View File

@@ -423,8 +423,21 @@ export interface FinancialAlert {
export type CryptoTransactionType = "buy" | "sell"; export type CryptoTransactionType = "buy" | "sell";
export interface CryptoPortfolio {
id: number;
name: string;
color: string | null;
is_archived: boolean;
}
export interface CryptoPortfolioInput {
name: string;
is_archived?: boolean;
}
export interface CryptoHolding { export interface CryptoHolding {
asset_id: number; asset_id: number;
portfolio_id: number;
coingecko_id: string; coingecko_id: string;
symbol: string; symbol: string;
name: string; name: string;
@@ -447,6 +460,10 @@ export interface CryptoHolding {
} }
export interface CryptoHoldingCreateInput { export interface CryptoHoldingCreateInput {
// Omit to file the coin under the default portfolio (auto-created if
// none exists yet — see services/crypto_service.py's
// get_or_create_default_portfolio).
portfolio_id?: number | null;
coingecko_id: string; coingecko_id: string;
symbol: string; symbol: string;
name: string; name: string;