mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: портфели в разделе «Криптовалюта»
Криптовалюту теперь можно разделять на отдельные портфели (например, долгосрочные инвестиции, накопления на недвижимость, спекулятивные монеты) вместо одного общего списка — по тому же принципу, что уже есть у счетов (Account): у портфеля есть имя, цветная метка и архивация, каждая монета обязательно принадлежит одному портфелю, один и тот же токен можно держать в нескольких портфелях сразу.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,4 +50,5 @@ dist/
|
||||
pgdata/
|
||||
|
||||
# Other
|
||||
UPDATES.md
|
||||
UPDATES.md
|
||||
temp/
|
||||
@@ -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')
|
||||
@@ -6,6 +6,9 @@ from app.schemas.crypto import (
|
||||
CryptoHistoryResponse,
|
||||
CryptoHoldingCreate,
|
||||
CryptoHoldingRead,
|
||||
CryptoPortfolioCreate,
|
||||
CryptoPortfolioRead,
|
||||
CryptoPortfolioUpdate,
|
||||
CryptoSearchResult,
|
||||
CryptoSyncResult,
|
||||
CryptoTransactionCreate,
|
||||
@@ -16,31 +19,65 @@ from app.services.crypto_service import (
|
||||
CRYPTO_RANGE_DAYS,
|
||||
add_transaction,
|
||||
create_holding,
|
||||
create_portfolio,
|
||||
delete_portfolio,
|
||||
delete_transaction,
|
||||
get_crypto_history,
|
||||
list_portfolios,
|
||||
list_transactions,
|
||||
refresh_prices,
|
||||
search_coins,
|
||||
update_portfolio,
|
||||
update_transaction,
|
||||
)
|
||||
|
||||
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)
|
||||
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
|
||||
once-a-day auto-refresh happens (see services/crypto_service.py):
|
||||
prices only actually get re-fetched from CoinGecko if 24h have passed
|
||||
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)
|
||||
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 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)
|
||||
@@ -89,6 +126,7 @@ _HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$"
|
||||
@router.get("/history", response_model=CryptoHistoryResponse)
|
||||
async def read_crypto_history(
|
||||
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
|
||||
portfolio_id: int | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CryptoHistoryResponse:
|
||||
return await get_crypto_history(session, range)
|
||||
return await get_crypto_history(session, range, portfolio_id)
|
||||
|
||||
@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
# 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
|
||||
# current one.
|
||||
APP_VERSION = "1.1.1"
|
||||
APP_VERSION = "1.1.2"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -2,7 +2,7 @@ from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.budget import Budget
|
||||
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.recurring import RecurringTransaction
|
||||
from app.models.settings import AppSettings
|
||||
@@ -17,6 +17,7 @@ __all__ = [
|
||||
"Budget",
|
||||
"Category",
|
||||
"CryptoHolding",
|
||||
"CryptoPortfolio",
|
||||
"CryptoSyncState",
|
||||
"CryptoTransaction",
|
||||
"Goal",
|
||||
|
||||
@@ -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
|
||||
also has to leave the average cost basis of what's still held unchanged
|
||||
(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 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 app.db.base import Base
|
||||
@@ -22,6 +27,26 @@ from app.models.enums import CryptoTransactionType
|
||||
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):
|
||||
"""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
|
||||
@@ -33,6 +58,13 @@ class CryptoHolding(Base, TimestampMixin):
|
||||
__tablename__ = "crypto_holdings"
|
||||
|
||||
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,
|
||||
# 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)
|
||||
|
||||
asset: Mapped["Asset"] = relationship()
|
||||
portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings")
|
||||
transactions: Mapped[list["CryptoTransaction"]] = relationship(
|
||||
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
|
||||
)
|
||||
|
||||
@@ -106,6 +106,15 @@ class AssetValuationBackup(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -114,6 +123,11 @@ class CryptoHoldingBackup(BaseModel):
|
||||
# quantity here — it's derived from crypto_transactions below, not
|
||||
# stored.
|
||||
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
|
||||
symbol: str
|
||||
name: str
|
||||
@@ -217,8 +231,9 @@ class BackupPayload(BaseModel):
|
||||
transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list)
|
||||
assets: list[AssetBackup]
|
||||
asset_valuations: list[AssetValuationBackup]
|
||||
# Defaulted so a backup exported before crypto holdings existed still
|
||||
# imports cleanly under the same format version.
|
||||
# Defaulted so a backup exported before crypto holdings/portfolios
|
||||
# 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_transactions: list[CryptoTransactionBackup] = Field(default_factory=list)
|
||||
# Defaulted so a backup exported before budgets existed still imports
|
||||
|
||||
@@ -39,7 +39,29 @@ class CryptoTransactionRead(BaseModel):
|
||||
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):
|
||||
# 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)
|
||||
symbol: str = Field(min_length=1, max_length=20)
|
||||
name: str = Field(min_length=1, max_length=150)
|
||||
@@ -54,6 +76,7 @@ class CryptoHoldingCreate(BaseModel):
|
||||
|
||||
class CryptoHoldingRead(BaseModel):
|
||||
asset_id: int
|
||||
portfolio_id: int
|
||||
coingecko_id: str
|
||||
symbol: str
|
||||
name: str
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.budget import Budget
|
||||
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.recurring import RecurringTransaction
|
||||
from app.models.settings import AppSettings
|
||||
@@ -36,6 +36,7 @@ from app.schemas.backup import (
|
||||
BudgetBackup,
|
||||
CategoryBackup,
|
||||
CryptoHoldingBackup,
|
||||
CryptoPortfolioBackup,
|
||||
CryptoTransactionBackup,
|
||||
GoalBackup,
|
||||
GoalContributionBackup,
|
||||
@@ -56,6 +57,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
||||
transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all()
|
||||
assets = (await session.execute(select(Asset))).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_transactions = (await session.execute(select(CryptoTransaction))).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],
|
||||
assets=[AssetBackup.model_validate(row) for row in assets],
|
||||
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_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions],
|
||||
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:
|
||||
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}
|
||||
for h in payload.crypto_holdings:
|
||||
if h.asset_id not in asset_ids:
|
||||
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:
|
||||
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(CryptoTransaction))
|
||||
await session.execute(delete(CryptoHolding))
|
||||
await session.execute(delete(CryptoPortfolio))
|
||||
await session.execute(delete(Budget))
|
||||
await session.execute(delete(GoalContribution))
|
||||
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(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(Budget(**row.model_dump()) for row in payload.budgets)
|
||||
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, "transaction_splits", payload.transaction_splits)
|
||||
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, "budgets", payload.budgets)
|
||||
await _reset_sequence(session, "goals", payload.goals)
|
||||
|
||||
@@ -34,13 +34,16 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
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.schemas.crypto import (
|
||||
CryptoHistoryPoint,
|
||||
CryptoHistoryResponse,
|
||||
CryptoHoldingCreate,
|
||||
CryptoHoldingRead,
|
||||
CryptoPortfolioCreate,
|
||||
CryptoPortfolioRead,
|
||||
CryptoPortfolioUpdate,
|
||||
CryptoSearchResult,
|
||||
CryptoSyncResult,
|
||||
CryptoTransactionCreate,
|
||||
@@ -48,6 +51,21 @@ from app.schemas.crypto import (
|
||||
)
|
||||
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's own Demo-tier data only refreshes every 60s server-side
|
||||
# (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
|
||||
return CryptoHoldingRead(
|
||||
asset_id=holding.asset_id,
|
||||
portfolio_id=holding.portfolio_id,
|
||||
coingecko_id=holding.coingecko_id,
|
||||
symbol=holding.symbol,
|
||||
name=holding.name,
|
||||
@@ -196,8 +215,77 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
|
||||
)
|
||||
|
||||
|
||||
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
|
||||
result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name))
|
||||
def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead:
|
||||
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())
|
||||
|
||||
|
||||
@@ -236,12 +324,17 @@ async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal
|
||||
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)
|
||||
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:
|
||||
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])
|
||||
|
||||
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
|
||||
# assignment, not a raw Core write) — no need to re-query, they already
|
||||
# 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(
|
||||
synced=error_key is None,
|
||||
last_synced_at=state.last_synced_at,
|
||||
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:
|
||||
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(
|
||||
name=payload.name,
|
||||
asset_class=AssetClass.CRYPTO,
|
||||
@@ -303,6 +405,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
|
||||
|
||||
holding = CryptoHolding(
|
||||
asset_id=asset.id,
|
||||
portfolio_id=portfolio_id,
|
||||
coingecko_id=payload.coingecko_id,
|
||||
symbol=payload.symbol.upper(),
|
||||
name=payload.name,
|
||||
@@ -440,19 +543,28 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None
|
||||
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
|
||||
Crypto tab. Same "cumulative point events, forward-filled" approach as
|
||||
net_worth_service.py's own asset series — duplicated here rather than
|
||||
imported, so this can't destabilize the already-tested Net Worth engine,
|
||||
and scoped to crypto-class assets only (a deleted holding's
|
||||
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()
|
||||
|
||||
crypto_asset_ids = set(
|
||||
(await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all()
|
||||
asset_stmt = (
|
||||
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:
|
||||
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
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"})
|
||||
|
||||
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() == []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
CryptoHistoryResponse,
|
||||
CryptoHolding,
|
||||
CryptoHoldingCreateInput,
|
||||
CryptoPortfolio,
|
||||
CryptoPortfolioInput,
|
||||
CryptoRange,
|
||||
CryptoSearchResult,
|
||||
CryptoSyncResult,
|
||||
@@ -10,14 +12,34 @@ import type {
|
||||
CryptoTransactionInput,
|
||||
} from "@/types";
|
||||
|
||||
// Also triggers the lazy once-a-day auto-refresh server-side — see
|
||||
// services/crypto_service.py.
|
||||
export function fetchCryptoHoldings() {
|
||||
return api.get<CryptoSyncResult>("/crypto/holdings");
|
||||
export function fetchCryptoPortfolios(includeArchived = false) {
|
||||
return api.get<CryptoPortfolio[]>(`/crypto/portfolios?include_archived=${includeArchived}`);
|
||||
}
|
||||
|
||||
export function refreshCryptoPrices() {
|
||||
return api.post<CryptoSyncResult>("/crypto/refresh", {});
|
||||
export function createCryptoPortfolio(input: CryptoPortfolioInput) {
|
||||
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) {
|
||||
@@ -52,6 +74,7 @@ export function searchCryptoCoins(query: string) {
|
||||
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
export function fetchCryptoHistory(range: CryptoRange) {
|
||||
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}`);
|
||||
export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | null) {
|
||||
const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : "";
|
||||
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}${query}`);
|
||||
}
|
||||
|
||||
@@ -3,28 +3,34 @@ import { ApiError } from "@/api/client";
|
||||
import { searchCryptoCoins } from "@/api/crypto";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useCreateCryptoHolding } from "@/hooks/useCrypto";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoSearchResult } from "@/types";
|
||||
|
||||
interface CryptoAddModalProps {
|
||||
open: boolean;
|
||||
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
|
||||
* enter how much of it is held. No separate multi-step wizard — unlike CSV
|
||||
* 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 createHolding = useCreateCryptoHolding();
|
||||
const { data: portfolios } = useCryptoPortfolios();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<CryptoSearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
|
||||
const [portfolioId, setPortfolioId] = useState<number | null>(null);
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [pricePerUnit, setPricePerUnit] = useState("");
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
@@ -42,6 +48,18 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
setSaveError(null);
|
||||
}, [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(() => {
|
||||
if (!open || selected) return;
|
||||
const trimmed = query.trim();
|
||||
@@ -73,6 +91,7 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await createHolding.mutateAsync({
|
||||
portfolio_id: portfolioId,
|
||||
coingecko_id: selected.coingecko_id,
|
||||
symbol: selected.symbol,
|
||||
name: selected.name,
|
||||
@@ -145,6 +164,23 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
</span>
|
||||
</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>
|
||||
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
||||
import { formatCurrency, maskAmount } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
import type { CryptoHolding, CryptoPortfolio } from "@/types";
|
||||
|
||||
interface CryptoHoldingsTableProps {
|
||||
items: CryptoHolding[];
|
||||
@@ -9,6 +9,11 @@ interface CryptoHoldingsTableProps {
|
||||
onTrade: (holding: CryptoHolding) => void;
|
||||
onViewHistory: (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 }) {
|
||||
@@ -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();
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -75,7 +87,20 @@ export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onD
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
124
frontend/src/components/crypto/CryptoPortfolioFormModal.tsx
Normal file
124
frontend/src/components/crypto/CryptoPortfolioFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/crypto/CryptoPortfolioTabs.tsx
Normal file
78
frontend/src/components/crypto/CryptoPortfolioTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
addCryptoTransaction,
|
||||
createCryptoHolding,
|
||||
createCryptoPortfolio,
|
||||
deleteCryptoHolding,
|
||||
deleteCryptoPortfolio,
|
||||
deleteCryptoTransaction,
|
||||
fetchCryptoHistory,
|
||||
fetchCryptoHoldings,
|
||||
fetchCryptoPortfolios,
|
||||
fetchCryptoTransactions,
|
||||
refreshCryptoPrices,
|
||||
updateCryptoPortfolio,
|
||||
updateCryptoTransaction,
|
||||
} from "@/api/crypto";
|
||||
import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
||||
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
||||
|
||||
function useInvalidateCrypto() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -24,12 +28,50 @@ function useInvalidateCrypto() {
|
||||
};
|
||||
}
|
||||
|
||||
export function useCryptoHoldings() {
|
||||
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
|
||||
export function useCryptoPortfolios(includeArchived = false) {
|
||||
return useQuery({
|
||||
queryKey: ["crypto-portfolios", includeArchived],
|
||||
queryFn: () => fetchCryptoPortfolios(includeArchived),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCryptoHistory(range: CryptoRange) {
|
||||
return useQuery({ queryKey: ["crypto-history", range], queryFn: () => fetchCryptoHistory(range) });
|
||||
export function useCreateCryptoPortfolio() {
|
||||
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) {
|
||||
@@ -42,7 +84,10 @@ export function useCryptoTransactions(assetId: number | null) {
|
||||
|
||||
export function useRefreshCryptoPrices() {
|
||||
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() {
|
||||
|
||||
@@ -162,6 +162,17 @@ const ru = {
|
||||
"crypto.stats.bestPerformer": "Лучший рост",
|
||||
"crypto.stats.worstPerformer": "Худшее падение",
|
||||
"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.investmentLabel": "Сумма инвестиции",
|
||||
@@ -645,6 +656,17 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"crypto.stats.bestPerformer": "Best Performer",
|
||||
"crypto.stats.worstPerformer": "Worst Performer",
|
||||
"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.investmentLabel": "Investment amount",
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
||||
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
||||
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 { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
||||
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 { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding, CryptoRange, CryptoTransaction } from "@/types";
|
||||
import type { CryptoHolding, CryptoPortfolio, CryptoRange, CryptoTransaction } from "@/types";
|
||||
|
||||
function formatSyncedAt(iso: string): string {
|
||||
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
|
||||
// component happened to own it.
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const { data, isLoading } = useCryptoHoldings();
|
||||
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range);
|
||||
// null = "All portfolios" tab.
|
||||
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 deleteHolding = useDeleteCryptoHolding();
|
||||
|
||||
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 [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
||||
// Set together with tradingHolding when editing an entry from the history
|
||||
@@ -41,6 +54,17 @@ export function CryptoPage() {
|
||||
|
||||
const holdings = data?.holdings ?? [];
|
||||
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) {
|
||||
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
||||
@@ -66,6 +90,14 @@ export function CryptoPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<CryptoPortfolioTabs
|
||||
portfolios={portfolios ?? []}
|
||||
activeId={portfolioFilter}
|
||||
onChange={setPortfolioFilter}
|
||||
onAdd={openAddPortfolioModal}
|
||||
onEdit={openEditPortfolioModal}
|
||||
/>
|
||||
|
||||
<CryptoOverviewCard
|
||||
totalValue={totalValue}
|
||||
history={history}
|
||||
@@ -113,13 +145,22 @@ export function CryptoPage() {
|
||||
onTrade={openTradeModal}
|
||||
onViewHistory={setHistoryHolding}
|
||||
onDelete={handleDelete}
|
||||
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</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
|
||||
open={tradingHolding !== null}
|
||||
onClose={closeTradeModal}
|
||||
|
||||
@@ -423,8 +423,21 @@ export interface FinancialAlert {
|
||||
|
||||
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 {
|
||||
asset_id: number;
|
||||
portfolio_id: number;
|
||||
coingecko_id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
@@ -447,6 +460,10 @@ export interface CryptoHolding {
|
||||
}
|
||||
|
||||
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;
|
||||
symbol: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user