mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: сеть (network) для крипто-холдингов
Свободное текстовое поле на холдинге — какой сети принадлежит конкретная монета (Ethereum, Tron и т.д.), с редактированием прямо в таблице и разбивкой % по сетям на отдельной вкладке. Помогает увидеть, что часть стейблкоинов лежит на рискованных сетях и стоит перевести на безопасные.
This commit is contained in:
21
DOCS.md
21
DOCS.md
@@ -581,6 +581,7 @@ of the whole request failing.
|
|||||||
| `GET` | `/crypto/holdings` | List holdings with live price, 1h/24h/7d % change, computed quantity/avg buy price/P&L. Also runs the lazy once-a-day auto-refresh. |
|
| `GET` | `/crypto/holdings` | List holdings with live price, 1h/24h/7d % change, computed quantity/avg buy price/P&L. Also runs the lazy once-a-day auto-refresh. |
|
||||||
| `POST` | `/crypto/refresh` | Force a price refresh right now, bypassing the 24h window. |
|
| `POST` | `/crypto/refresh` | Force a price refresh right now, bypassing the 24h window. |
|
||||||
| `POST` | `/crypto/holdings` | Add a new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't `null` until the next sync. |
|
| `POST` | `/crypto/holdings` | Add a new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't `null` until the next sync. |
|
||||||
|
| `PATCH` | `/crypto/holdings/{asset_id}` | Update holding-only metadata (currently just `network`) — quantity/price/date go through the transaction endpoints below instead, and `risk_level` through `PATCH /assets/{asset_id}` (see [Assets & Net Worth](#assets--net-worth)). |
|
||||||
| `POST` | `/crypto/holdings/{asset_id}/transactions` | Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. `400` if a sell would exceed what's currently held. |
|
| `POST` | `/crypto/holdings/{asset_id}/transactions` | Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. `400` if a sell would exceed what's currently held. |
|
||||||
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
||||||
| `PATCH` | `/crypto/transactions/{transaction_id}` | Edit an existing transaction (partial — send only the fields you're changing). Never calls CoinGecko. `400` if changing a sell's quantity would exceed what the rest of the log leaves held. |
|
| `PATCH` | `/crypto/transactions/{transaction_id}` | Edit an existing transaction (partial — send only the fields you're changing). Never calls CoinGecko. `400` if changing a sell's quantity would exceed what the rest of the log leaves held. |
|
||||||
@@ -599,14 +600,28 @@ of the whole request failing.
|
|||||||
"quantity": "0.05",
|
"quantity": "0.05",
|
||||||
"price_per_unit": "55000",
|
"price_per_unit": "55000",
|
||||||
"date": "2026-08-01",
|
"date": "2026-08-01",
|
||||||
"note": null
|
"note": null,
|
||||||
|
"risk_level": "high",
|
||||||
|
"network": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`coingecko_id` is CoinGecko's own stable id (not the ticker — tickers collide across unrelated
|
`coingecko_id` is CoinGecko's own stable id (not the ticker — tickers collide across unrelated
|
||||||
coins) — get it from `/crypto/search` rather than guessing. `quantity`/`price_per_unit` support up to
|
coins) — get it from `/crypto/search` rather than guessing. `quantity`/`price_per_unit` support up to
|
||||||
18 decimal places (wei-level token amounts). `price_per_unit` is what you actually paid, in the app's
|
18 decimal places (wei-level token amounts). `price_per_unit` is what you actually paid, in the app's
|
||||||
display currency — it's stored as-is, never re-derived from market data later.
|
display currency — it's stored as-is, never re-derived from market data later. `risk_level` is
|
||||||
|
optional, defaults to `high`. `network` is optional free text (e.g. `"Ethereum"`, `"Tron"`) — not
|
||||||
|
validated against a fixed list, since the same coin can be issued on chains CoinGecko doesn't
|
||||||
|
distinguish by `coingecko_id` alone (a stablecoin held across several networks carries different
|
||||||
|
bridge/counterparty risk per chain, which `risk_level` alone can't capture).
|
||||||
|
|
||||||
|
**Update holding metadata** (`PATCH /crypto/holdings/{asset_id}`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "network": "Arbitrum" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Partial like every other `PATCH` — omit `network` to leave it unchanged, send `null` to clear it.
|
||||||
|
|
||||||
**Add a transaction** (`POST /crypto/holdings/{asset_id}/transactions`):
|
**Add a transaction** (`POST /crypto/holdings/{asset_id}/transactions`):
|
||||||
|
|
||||||
@@ -630,6 +645,8 @@ display currency — it's stored as-is, never re-derived from market data later.
|
|||||||
"symbol": "BTC",
|
"symbol": "BTC",
|
||||||
"name": "Bitcoin",
|
"name": "Bitcoin",
|
||||||
"thumb_url": "https://...",
|
"thumb_url": "https://...",
|
||||||
|
"risk_level": "high",
|
||||||
|
"network": null,
|
||||||
"quantity": "0.05",
|
"quantity": "0.05",
|
||||||
"avg_buy_price": "55000.00",
|
"avg_buy_price": "55000.00",
|
||||||
"current_price": "61000.00",
|
"current_price": "61000.00",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add network field to crypto holdings
|
||||||
|
|
||||||
|
Revision ID: d1a6f4c8b729
|
||||||
|
Revises: c4e8f61a9d23
|
||||||
|
Create Date: 2026-09-08 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'd1a6f4c8b729'
|
||||||
|
down_revision: Union[str, None] = 'c4e8f61a9d23'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('crypto_holdings', sa.Column('network', sa.String(length=50), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('crypto_holdings', 'network')
|
||||||
@@ -6,6 +6,7 @@ from app.schemas.crypto import (
|
|||||||
CryptoHistoryResponse,
|
CryptoHistoryResponse,
|
||||||
CryptoHoldingCreate,
|
CryptoHoldingCreate,
|
||||||
CryptoHoldingRead,
|
CryptoHoldingRead,
|
||||||
|
CryptoHoldingUpdate,
|
||||||
CryptoPerformanceResponse,
|
CryptoPerformanceResponse,
|
||||||
CryptoPortfolioCreate,
|
CryptoPortfolioCreate,
|
||||||
CryptoPortfolioRead,
|
CryptoPortfolioRead,
|
||||||
@@ -29,6 +30,7 @@ from app.services.crypto_service import (
|
|||||||
list_transactions,
|
list_transactions,
|
||||||
refresh_prices,
|
refresh_prices,
|
||||||
search_coins,
|
search_coins,
|
||||||
|
update_holding,
|
||||||
update_portfolio,
|
update_portfolio,
|
||||||
update_transaction,
|
update_transaction,
|
||||||
)
|
)
|
||||||
@@ -89,6 +91,15 @@ async def create_holding_route(
|
|||||||
return await create_holding(session, payload)
|
return await create_holding(session, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/holdings/{asset_id}", response_model=CryptoHoldingRead)
|
||||||
|
async def update_holding_route(
|
||||||
|
asset_id: int, payload: CryptoHoldingUpdate, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> CryptoHoldingRead:
|
||||||
|
"""Updates holding-only metadata (currently just `network`) — quantity/
|
||||||
|
price/date go through the /transactions routes below instead."""
|
||||||
|
return await update_holding(session, asset_id, payload)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/holdings/{asset_id}/transactions", response_model=CryptoHoldingRead, status_code=201)
|
@router.post("/holdings/{asset_id}/transactions", response_model=CryptoHoldingRead, status_code=201)
|
||||||
async def add_transaction_route(
|
async def add_transaction_route(
|
||||||
asset_id: int, payload: CryptoTransactionCreate, session: AsyncSession = Depends(get_session)
|
asset_id: int, payload: CryptoTransactionCreate, session: AsyncSession = Depends(get_session)
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ class CryptoHolding(Base, TimestampMixin):
|
|||||||
symbol: Mapped[str] = mapped_column(String(20), nullable=False)
|
symbol: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||||
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
# Free-form, user-entered — which chain this specific holding actually
|
||||||
|
# sits on (e.g. "Ethereum", "Tron", "Arbitrum"). Deliberately not an
|
||||||
|
# enum: CoinGecko doesn't expose per-holding chain data (the same
|
||||||
|
# coingecko_id like "tether" covers the token across every chain it's
|
||||||
|
# issued on), and new chains appear too often for a fixed list to keep
|
||||||
|
# up. Lets a stablecoin held across several networks be told apart by
|
||||||
|
# counterparty/bridge risk, which risk_level alone can't capture.
|
||||||
|
network: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
|
||||||
# Cached from the last successful sync (services/crypto_service.py) —
|
# Cached from the last successful sync (services/crypto_service.py) —
|
||||||
# in the app's display currency (see AppSettings.currency). Null until
|
# in the app's display currency (see AppSettings.currency). Null until
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ class CryptoHoldingCreate(BaseModel):
|
|||||||
# services/crypto_service.py) — but user-overridable at add time, since
|
# services/crypto_service.py) — but user-overridable at add time, since
|
||||||
# not every coin carries the same risk (a large-cap vs. a memecoin).
|
# not every coin carries the same risk (a large-cap vs. a memecoin).
|
||||||
risk_level: RiskLevel = RiskLevel.HIGH
|
risk_level: RiskLevel = RiskLevel.HIGH
|
||||||
|
# Free-form chain name (e.g. "Ethereum", "Tron") — optional, not
|
||||||
|
# validated against a fixed list (see CryptoHolding.network).
|
||||||
|
network: str | None = Field(default=None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoHoldingUpdate(BaseModel):
|
||||||
|
"""Holding-only metadata that isn't part of the buy/sell transaction
|
||||||
|
log — currently just `network`. Quantity/price/date changes go through
|
||||||
|
the /transactions routes instead (see add_transaction/update_transaction),
|
||||||
|
same split as risk_level living on the Asset via PATCH /assets/{id}."""
|
||||||
|
|
||||||
|
network: str | None = Field(default=None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class CryptoHoldingRead(BaseModel):
|
class CryptoHoldingRead(BaseModel):
|
||||||
@@ -90,6 +102,8 @@ class CryptoHoldingRead(BaseModel):
|
|||||||
# same endpoint every other Asset already uses, no crypto-specific
|
# same endpoint every other Asset already uses, no crypto-specific
|
||||||
# update route needed.
|
# update route needed.
|
||||||
risk_level: RiskLevel
|
risk_level: RiskLevel
|
||||||
|
# Lives on CryptoHolding itself — editable via PATCH /crypto/holdings/{asset_id}.
|
||||||
|
network: str | None
|
||||||
|
|
||||||
# Quantity and avg_buy_price are derived from the transaction log (see
|
# Quantity and avg_buy_price are derived from the transaction log (see
|
||||||
# services/crypto_service.py's _compute_position) — never stored.
|
# services/crypto_service.py's _compute_position) — never stored.
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from app.schemas.crypto import (
|
|||||||
CryptoHistoryResponse,
|
CryptoHistoryResponse,
|
||||||
CryptoHoldingCreate,
|
CryptoHoldingCreate,
|
||||||
CryptoHoldingRead,
|
CryptoHoldingRead,
|
||||||
|
CryptoHoldingUpdate,
|
||||||
CryptoPerformancePoint,
|
CryptoPerformancePoint,
|
||||||
CryptoPerformanceResponse,
|
CryptoPerformanceResponse,
|
||||||
CryptoPortfolioCreate,
|
CryptoPortfolioCreate,
|
||||||
@@ -217,6 +218,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
|
|||||||
name=holding.name,
|
name=holding.name,
|
||||||
thumb_url=holding.thumb_url,
|
thumb_url=holding.thumb_url,
|
||||||
risk_level=holding.asset.risk_level,
|
risk_level=holding.asset.risk_level,
|
||||||
|
network=holding.network,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
avg_buy_price=avg_buy_price,
|
avg_buy_price=avg_buy_price,
|
||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
@@ -442,6 +444,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
|
|||||||
symbol=payload.symbol.upper(),
|
symbol=payload.symbol.upper(),
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
thumb_url=payload.thumb_url,
|
thumb_url=payload.thumb_url,
|
||||||
|
network=payload.network,
|
||||||
)
|
)
|
||||||
# Wired explicitly rather than left for a lazy load off asset_id — under
|
# Wired explicitly rather than left for a lazy load off asset_id — under
|
||||||
# async SQLAlchemy, a relationship access with no eager load and no prior
|
# async SQLAlchemy, a relationship access with no eager load and no prior
|
||||||
@@ -488,6 +491,17 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
|
|||||||
return _to_read(holding)
|
return _to_read(holding)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_holding(session: AsyncSession, asset_id: int, payload: CryptoHoldingUpdate) -> CryptoHoldingRead:
|
||||||
|
"""Updates CryptoHolding-only metadata — currently just `network`.
|
||||||
|
Quantity/price/date go through add_transaction/update_transaction
|
||||||
|
instead; risk_level lives on the Asset via PATCH /assets/{asset_id}."""
|
||||||
|
holding = await _get_holding_or_404(session, asset_id)
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(holding, field, value)
|
||||||
|
await session.commit()
|
||||||
|
return _to_read(holding)
|
||||||
|
|
||||||
|
|
||||||
async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoTransactionCreate) -> CryptoHoldingRead:
|
async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoTransactionCreate) -> CryptoHoldingRead:
|
||||||
"""A buy or sell against an existing holding — never calls CoinGecko
|
"""A buy or sell against an existing holding — never calls CoinGecko
|
||||||
(see module docstring): value is recomputed from the last cached
|
(see module docstring): value is recomputed from the last cached
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CryptoHistoryResponse,
|
CryptoHistoryResponse,
|
||||||
CryptoHolding,
|
CryptoHolding,
|
||||||
CryptoHoldingCreateInput,
|
CryptoHoldingCreateInput,
|
||||||
|
CryptoHoldingUpdateInput,
|
||||||
CryptoPerformanceResponse,
|
CryptoPerformanceResponse,
|
||||||
CryptoPortfolio,
|
CryptoPortfolio,
|
||||||
CryptoPortfolioInput,
|
CryptoPortfolioInput,
|
||||||
@@ -48,6 +49,13 @@ export function createCryptoHolding(input: CryptoHoldingCreateInput) {
|
|||||||
return api.post<CryptoHolding>("/crypto/holdings", input);
|
return api.post<CryptoHolding>("/crypto/holdings", input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Holding-only metadata (currently just `network`) — quantity/price/date
|
||||||
|
// live on the transaction log, risk_level on the underlying Asset (see
|
||||||
|
// updateCryptoHoldingRisk above).
|
||||||
|
export function updateCryptoHolding(assetId: number, input: CryptoHoldingUpdateInput) {
|
||||||
|
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
// Buy more of, or sell some of, a coin already being tracked.
|
// Buy more of, or sell some of, a coin already being tracked.
|
||||||
export function addCryptoTransaction(assetId: number, input: CryptoTransactionInput) {
|
export function addCryptoTransaction(assetId: number, input: CryptoTransactionInput) {
|
||||||
return api.post<CryptoHolding>(`/crypto/holdings/${assetId}/transactions`, input);
|
return api.post<CryptoHolding>(`/crypto/holdings/${assetId}/transactions`, input);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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, Select } from "@/components/ui/Input";
|
import { Input, Label, Select } from "@/components/ui/Input";
|
||||||
import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
|
import { useCreateCryptoHolding, useCryptoHoldings, useCryptoPortfolios } from "@/hooks/useCrypto";
|
||||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||||
import type { CryptoSearchResult, RiskLevel } from "@/types";
|
import type { CryptoSearchResult, RiskLevel } from "@/types";
|
||||||
|
|
||||||
@@ -26,6 +26,13 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const createHolding = useCreateCryptoHolding();
|
const createHolding = useCreateCryptoHolding();
|
||||||
const { data: portfolios } = useCryptoPortfolios();
|
const { data: portfolios } = useCryptoPortfolios();
|
||||||
|
// Every portfolio's holdings, purely to build the network datalist below
|
||||||
|
// (already-typed values like "Ethereum"/"Tron" as autocomplete, not a
|
||||||
|
// fixed list — see CryptoHolding.network).
|
||||||
|
const { data: allHoldings } = useCryptoHoldings();
|
||||||
|
const knownNetworks = Array.from(
|
||||||
|
new Set((allHoldings?.holdings ?? []).map((h) => h.network).filter((n): n is string => Boolean(n)))
|
||||||
|
).sort();
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<CryptoSearchResult[]>([]);
|
const [results, setResults] = useState<CryptoSearchResult[]>([]);
|
||||||
@@ -39,6 +46,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
|||||||
// Defaults to "high", same as the backend default — crypto is this app's
|
// Defaults to "high", same as the backend default — crypto is this app's
|
||||||
// own textbook HIGH risk example — but overridable per coin here.
|
// own textbook HIGH risk example — but overridable per coin here.
|
||||||
const [riskLevel, setRiskLevel] = useState<RiskLevel>("high");
|
const [riskLevel, setRiskLevel] = useState<RiskLevel>("high");
|
||||||
|
const [network, setNetwork] = useState("");
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +58,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
|||||||
setPricePerUnit("");
|
setPricePerUnit("");
|
||||||
setDate(new Date().toISOString().slice(0, 10));
|
setDate(new Date().toISOString().slice(0, 10));
|
||||||
setRiskLevel("high");
|
setRiskLevel("high");
|
||||||
|
setNetwork("");
|
||||||
setSearchError(null);
|
setSearchError(null);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -106,6 +115,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
|||||||
price_per_unit: pricePerUnit,
|
price_per_unit: pricePerUnit,
|
||||||
date,
|
date,
|
||||||
risk_level: riskLevel,
|
risk_level: riskLevel,
|
||||||
|
network: network.trim() || null,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -242,6 +252,22 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="crypto-network">{t("crypto.form.networkLabel")}</Label>
|
||||||
|
<Input
|
||||||
|
id="crypto-network"
|
||||||
|
list="crypto-known-networks"
|
||||||
|
placeholder={t("crypto.form.networkPlaceholder")}
|
||||||
|
value={network}
|
||||||
|
onChange={(event) => setNetwork(event.target.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
<datalist id="crypto-known-networks">
|
||||||
|
{knownNetworks.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
{saveError && <p className="text-sm text-danger">{saveError}</p>}
|
{saveError && <p className="text-sm text-danger">{saveError}</p>}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { NetworkPicker } from "@/components/crypto/NetworkPicker";
|
||||||
import { RiskLevelPicker } from "@/components/crypto/RiskLevelPicker";
|
import { RiskLevelPicker } from "@/components/crypto/RiskLevelPicker";
|
||||||
import { formatCryptoAmount, maskAmount } from "@/lib/format";
|
import { formatCryptoAmount, maskAmount } from "@/lib/format";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
@@ -11,6 +12,7 @@ interface CryptoHoldingsTableProps {
|
|||||||
onViewHistory: (holding: CryptoHolding) => void;
|
onViewHistory: (holding: CryptoHolding) => void;
|
||||||
onDelete: (holding: CryptoHolding) => void;
|
onDelete: (holding: CryptoHolding) => void;
|
||||||
onRiskChange: (holding: CryptoHolding, riskLevel: RiskLevel) => void;
|
onRiskChange: (holding: CryptoHolding, riskLevel: RiskLevel) => void;
|
||||||
|
onNetworkChange: (holding: CryptoHolding, network: string | null) => void;
|
||||||
// Only passed while viewing the "All" tab — renders each row's portfolio
|
// 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
|
// 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,
|
// portfolio it belongs to. Omitted while a single portfolio is selected,
|
||||||
@@ -37,6 +39,7 @@ export function CryptoHoldingsTable({
|
|||||||
onViewHistory,
|
onViewHistory,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRiskChange,
|
onRiskChange,
|
||||||
|
onNetworkChange,
|
||||||
portfoliosById,
|
portfoliosById,
|
||||||
}: CryptoHoldingsTableProps) {
|
}: CryptoHoldingsTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -45,9 +48,13 @@ export function CryptoHoldingsTable({
|
|||||||
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every network already typed anywhere in this table, for NetworkPicker's
|
||||||
|
// autocomplete — deduped and sorted, same idea as CryptoAddModal's own list.
|
||||||
|
const knownNetworks = Array.from(new Set(items.map((h) => h.network).filter((n): n is string => Boolean(n)))).sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[880px] text-sm">
|
<table className="w-full min-w-[960px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left text-xs text-text-muted">
|
<tr className="border-b border-border text-left text-xs text-text-muted">
|
||||||
<th className="py-2.5 pr-4 font-medium">{t("crypto.table.name")}</th>
|
<th className="py-2.5 pr-4 font-medium">{t("crypto.table.name")}</th>
|
||||||
@@ -59,6 +66,7 @@ export function CryptoHoldingsTable({
|
|||||||
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th>
|
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th>
|
||||||
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.profitLoss")}</th>
|
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.profitLoss")}</th>
|
||||||
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.risk")}</th>
|
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.risk")}</th>
|
||||||
|
<th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.network")}</th>
|
||||||
<th className="whitespace-nowrap py-2.5 pl-4 text-right font-medium">{t("crypto.table.actions")}</th>
|
<th className="whitespace-nowrap py-2.5 pl-4 text-right font-medium">{t("crypto.table.actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -154,10 +162,19 @@ export function CryptoHoldingsTable({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 pr-4 align-middle" onClick={(event) => event.stopPropagation()}>
|
<td className="py-3.5 pr-4 align-middle" onClick={(event) => event.stopPropagation()}>
|
||||||
<span className="flex justify-end">
|
<span className="flex w-full justify-end">
|
||||||
<RiskLevelPicker value={holding.risk_level} onChange={(level) => onRiskChange(holding, level)} />
|
<RiskLevelPicker value={holding.risk_level} onChange={(level) => onRiskChange(holding, level)} />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 pr-4 align-middle" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<span className="flex w-full justify-end">
|
||||||
|
<NetworkPicker
|
||||||
|
value={holding.network}
|
||||||
|
knownNetworks={knownNetworks}
|
||||||
|
onChange={(network) => onNetworkChange(holding, network)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="py-3.5 pl-4 align-middle">
|
<td className="py-3.5 pl-4 align-middle">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
148
frontend/src/components/crypto/CryptoNetworkAllocationBody.tsx
Normal file
148
frontend/src/components/crypto/CryptoNetworkAllocationBody.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { formatCryptoAmount, maskAmount } from "@/lib/format";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import type { CryptoHolding } from "@/types";
|
||||||
|
|
||||||
|
interface CryptoNetworkAllocationBodyProps {
|
||||||
|
holdings: CryptoHolding[];
|
||||||
|
isLoading: boolean;
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same fixed categorical ramp as CryptoAllocationBody's per-coin donut —
|
||||||
|
// see net_worth_service.py's _CLASS_META comment for why this exact order.
|
||||||
|
const SERIES_COLORS = [
|
||||||
|
"var(--series-1)",
|
||||||
|
"var(--series-2)",
|
||||||
|
"var(--series-3)",
|
||||||
|
"var(--series-4)",
|
||||||
|
"var(--series-5)",
|
||||||
|
"var(--series-6)",
|
||||||
|
"var(--series-7)",
|
||||||
|
];
|
||||||
|
const OTHER_COLOR = "var(--series-other)";
|
||||||
|
const MAX_SLICES = SERIES_COLORS.length;
|
||||||
|
|
||||||
|
export interface Slice {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported for direct unit testing (see CryptoNetworkAllocationBody.test.ts)
|
||||||
|
// — the donut/table JSX below isn't worth rendering just to check the math.
|
||||||
|
export function buildSlices(holdings: CryptoHolding[], unsetLabel: string): Slice[] {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const h of holdings) {
|
||||||
|
if (h.value === null) continue;
|
||||||
|
const key = h.network ?? unsetLabel;
|
||||||
|
totals.set(key, (totals.get(key) ?? 0) + Number(h.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = Array.from(totals.entries())
|
||||||
|
.map(([label, amount]) => ({ key: label, label, amount }))
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
const top = sorted.slice(0, MAX_SLICES).map((item, index) => ({ ...item, color: SERIES_COLORS[index] }));
|
||||||
|
const rest = sorted.slice(MAX_SLICES);
|
||||||
|
if (rest.length === 0) return top;
|
||||||
|
|
||||||
|
const otherTotal = rest.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
return [...top, { key: "__other__", label: "__other__", amount: otherTotal, color: OTHER_COLOR }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function DonutTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
hidden,
|
||||||
|
otherLabel,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload: Slice & { percent: number } }>;
|
||||||
|
hidden: boolean;
|
||||||
|
otherLabel: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const slice = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||||
|
<p className="font-medium text-text-primary">{slice.key === "__other__" ? otherLabel : slice.label}</p>
|
||||||
|
<p className="text-text-muted">
|
||||||
|
{maskAmount(formatCryptoAmount(slice.amount), hidden)} · {slice.percent.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "Networks" tab's content inside CryptoOverviewCard — same donut +
|
||||||
|
* table shape as CryptoAllocationBody's per-coin view, grouped by
|
||||||
|
* `holding.network` instead, so a stablecoin split across several chains
|
||||||
|
* shows up as separate slices (bridge/counterparty risk differs by chain,
|
||||||
|
* which risk_level alone doesn't capture — see CryptoHolding.network).
|
||||||
|
* Holdings with no network set fold into one "unset" slice rather than
|
||||||
|
* disappearing, so nothing silently drops out of the total. */
|
||||||
|
export function CryptoNetworkAllocationBody({ holdings, isLoading, hidden }: CryptoNetworkAllocationBodyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unsetLabel = t("crypto.networkAllocation.unset");
|
||||||
|
const otherLabel = t("crypto.allocation.other");
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>;
|
||||||
|
}
|
||||||
|
const slices = buildSlices(holdings, unsetLabel);
|
||||||
|
if (slices.length === 0) {
|
||||||
|
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
|
||||||
|
const donutData = slices.map((slice) => ({ ...slice, percent: (slice.amount / total) * 100 }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-6 sm:flex-row">
|
||||||
|
<div className="h-48 w-48 shrink-0 sm:h-56 sm:w-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={donutData}
|
||||||
|
dataKey="amount"
|
||||||
|
nameKey="label"
|
||||||
|
innerRadius="68%"
|
||||||
|
outerRadius="100%"
|
||||||
|
paddingAngle={donutData.length > 1 ? 2 : 0}
|
||||||
|
stroke="var(--surface-1)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
>
|
||||||
|
{donutData.map((slice) => (
|
||||||
|
<Cell key={slice.key} fill={slice.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<DonutTooltip hidden={hidden} otherLabel={otherLabel} />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="text-sm">
|
||||||
|
<tbody>
|
||||||
|
{donutData.map((slice) => (
|
||||||
|
<tr key={slice.key} title={slice.key === "__other__" ? undefined : slice.label}>
|
||||||
|
<td className="py-1.5 pr-3">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: slice.color }} />
|
||||||
|
<span className="max-w-[140px] truncate font-medium text-text-primary">
|
||||||
|
{slice.key === "__other__" ? otherLabel : slice.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pr-3 text-right font-medium tabular-nums text-text-primary">
|
||||||
|
{slice.percent.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums text-text-muted">{maskAmount(formatCryptoAmount(slice.amount), hidden)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, Eye, EyeOff } from "lucide-react";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody";
|
import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody";
|
||||||
import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody";
|
import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody";
|
||||||
|
import { CryptoNetworkAllocationBody } from "@/components/crypto/CryptoNetworkAllocationBody";
|
||||||
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
|
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
|
||||||
import { CryptoRiskAllocationBody } from "@/components/crypto/CryptoRiskAllocationBody";
|
import { CryptoRiskAllocationBody } from "@/components/crypto/CryptoRiskAllocationBody";
|
||||||
import { PillSelector } from "@/components/layout/PillSelector";
|
import { PillSelector } from "@/components/layout/PillSelector";
|
||||||
@@ -10,7 +11,7 @@ import { formatCurrency, formatSignedCurrency, maskAmount } from "@/lib/format";
|
|||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types";
|
import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types";
|
||||||
|
|
||||||
type OverviewTab = "history" | "allocation" | "risk";
|
type OverviewTab = "history" | "allocation" | "risk" | "networks";
|
||||||
|
|
||||||
interface CryptoOverviewCardProps {
|
interface CryptoOverviewCardProps {
|
||||||
totalValue: number;
|
totalValue: number;
|
||||||
@@ -24,11 +25,12 @@ interface CryptoOverviewCardProps {
|
|||||||
onToggleHidden: () => void;
|
onToggleHidden: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One card, three tabs — "History" (the value-over-time chart),
|
/** One card, four tabs — "History" (the value-over-time chart),
|
||||||
* "Allocation" (a donut broken down by coin), and "Risk levels" (% of the
|
* "Allocation" (a donut broken down by coin), "Risk levels" (% of the
|
||||||
* portfolio in each RiskLevel tier), same CoinMarketCap-style layout the
|
* portfolio in each RiskLevel tier), and "Networks" (a donut broken down by
|
||||||
* user asked for: switching views instead of stacking separate cards,
|
* CryptoHolding.network) — same CoinMarketCap-style layout the user asked
|
||||||
* so the tab keeps its footprint on the page instead of growing it. The
|
* for: switching views instead of stacking separate cards, so the tab keeps
|
||||||
|
* its footprint on the page instead of growing it. The
|
||||||
* total/eye toggle/change line stay in the shared header regardless of
|
* total/eye toggle/change line stay in the shared header regardless of
|
||||||
* which tab is active — only the content panel below switches. */
|
* which tab is active — only the content panel below switches. */
|
||||||
export function CryptoOverviewCard({
|
export function CryptoOverviewCard({
|
||||||
@@ -80,6 +82,7 @@ export function CryptoOverviewCard({
|
|||||||
{ value: "history" as const, label: t("crypto.overview.history") },
|
{ value: "history" as const, label: t("crypto.overview.history") },
|
||||||
{ value: "allocation" as const, label: t("crypto.overview.allocation") },
|
{ value: "allocation" as const, label: t("crypto.overview.allocation") },
|
||||||
{ value: "risk" as const, label: t("crypto.overview.risk") },
|
{ value: "risk" as const, label: t("crypto.overview.risk") },
|
||||||
|
{ value: "networks" as const, label: t("crypto.overview.networks") },
|
||||||
]}
|
]}
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -92,8 +95,10 @@ export function CryptoOverviewCard({
|
|||||||
<CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} />
|
<CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} />
|
||||||
) : tab === "allocation" ? (
|
) : tab === "allocation" ? (
|
||||||
<CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
<CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
||||||
) : (
|
) : tab === "risk" ? (
|
||||||
<CryptoRiskAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
<CryptoRiskAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
||||||
|
) : (
|
||||||
|
<CryptoNetworkAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
67
frontend/src/components/crypto/NetworkPicker.tsx
Normal file
67
frontend/src/components/crypto/NetworkPicker.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useId, useState } from "react";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
|
interface NetworkPickerProps {
|
||||||
|
value: string | null;
|
||||||
|
knownNetworks: string[];
|
||||||
|
onChange: (network: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click-to-edit-in-place text for a holding's network (see
|
||||||
|
* CryptoHolding.network) — a plain button that swaps for a text input on
|
||||||
|
* click, autocompleting from every network already typed elsewhere via
|
||||||
|
* `knownNetworks`. No fixed list: chains are numerous and change too often
|
||||||
|
* for an enum, so this stays free text. */
|
||||||
|
export function NetworkPicker({ value, knownNetworks, onChange }: NetworkPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value ?? "");
|
||||||
|
const listId = useId();
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
setOpen(false);
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed !== (value ?? "")) onChange(trimmed || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
list={listId}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value.toUpperCase())}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-28 rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-text-primary outline-none"
|
||||||
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{knownNetworks.map((name) => (
|
||||||
|
<option key={name} value={name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(value ?? "");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
title={value ?? undefined}
|
||||||
|
className="max-w-[100px] truncate rounded px-1.5 py-0.5 text-right text-xs text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{value ?? t("crypto.table.addNetwork")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,11 +12,19 @@ import {
|
|||||||
fetchCryptoPortfolios,
|
fetchCryptoPortfolios,
|
||||||
fetchCryptoTransactions,
|
fetchCryptoTransactions,
|
||||||
refreshCryptoPrices,
|
refreshCryptoPrices,
|
||||||
|
updateCryptoHolding,
|
||||||
updateCryptoHoldingRisk,
|
updateCryptoHoldingRisk,
|
||||||
updateCryptoPortfolio,
|
updateCryptoPortfolio,
|
||||||
updateCryptoTransaction,
|
updateCryptoTransaction,
|
||||||
} from "@/api/crypto";
|
} from "@/api/crypto";
|
||||||
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput, RiskLevel } from "@/types";
|
import type {
|
||||||
|
CryptoHoldingCreateInput,
|
||||||
|
CryptoHoldingUpdateInput,
|
||||||
|
CryptoPortfolioInput,
|
||||||
|
CryptoRange,
|
||||||
|
CryptoTransactionInput,
|
||||||
|
RiskLevel,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
function useInvalidateCrypto() {
|
function useInvalidateCrypto() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -153,3 +161,12 @@ export function useUpdateCryptoHoldingRisk() {
|
|||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useUpdateCryptoHolding() {
|
||||||
|
const invalidate = useInvalidateCrypto();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ assetId, input }: { assetId: number; input: CryptoHoldingUpdateInput }) =>
|
||||||
|
updateCryptoHolding(assetId, input),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ const ru = {
|
|||||||
"crypto.form.pricePerUnitLabel": "Цена за штуку",
|
"crypto.form.pricePerUnitLabel": "Цена за штуку",
|
||||||
"crypto.form.dateLabel": "Дата",
|
"crypto.form.dateLabel": "Дата",
|
||||||
"crypto.form.noteLabel": "Заметка (необязательно)",
|
"crypto.form.noteLabel": "Заметка (необязательно)",
|
||||||
|
"crypto.form.networkLabel": "Сеть (необязательно)",
|
||||||
|
"crypto.form.networkPlaceholder": "Например, Ethereum, Tron…",
|
||||||
"crypto.form.addTitle": "Добавить {{name}}",
|
"crypto.form.addTitle": "Добавить {{name}}",
|
||||||
"crypto.form.tradeTitle": "Сделка: {{name}}",
|
"crypto.form.tradeTitle": "Сделка: {{name}}",
|
||||||
"crypto.form.editTradeTitle": "Изменить сделку: {{name}}",
|
"crypto.form.editTradeTitle": "Изменить сделку: {{name}}",
|
||||||
@@ -144,6 +146,8 @@ const ru = {
|
|||||||
"crypto.table.holdings": "Держите",
|
"crypto.table.holdings": "Держите",
|
||||||
"crypto.table.avgBuyPrice": "Ср. цена",
|
"crypto.table.avgBuyPrice": "Ср. цена",
|
||||||
"crypto.table.profitLoss": "Прибыль/убыток",
|
"crypto.table.profitLoss": "Прибыль/убыток",
|
||||||
|
"crypto.table.network": "Сеть",
|
||||||
|
"crypto.table.addNetwork": "+ Сеть",
|
||||||
"crypto.table.risk": "Риск",
|
"crypto.table.risk": "Риск",
|
||||||
"crypto.table.actions": "Действия",
|
"crypto.table.actions": "Действия",
|
||||||
"crypto.riskAllocationOfPortfolio": "от крипто-портфеля",
|
"crypto.riskAllocationOfPortfolio": "от крипто-портфеля",
|
||||||
@@ -158,6 +162,8 @@ const ru = {
|
|||||||
"crypto.overview.history": "История",
|
"crypto.overview.history": "История",
|
||||||
"crypto.overview.allocation": "Аллокация",
|
"crypto.overview.allocation": "Аллокация",
|
||||||
"crypto.overview.risk": "Уровни риска",
|
"crypto.overview.risk": "Уровни риска",
|
||||||
|
"crypto.overview.networks": "Сети",
|
||||||
|
"crypto.networkAllocation.unset": "Сеть не указана",
|
||||||
"crypto.allocation.other": "Другое",
|
"crypto.allocation.other": "Другое",
|
||||||
"crypto.stats.allTimeProfit": "Прибыль за всё время",
|
"crypto.stats.allTimeProfit": "Прибыль за всё время",
|
||||||
"crypto.stats.costBasis": "Вложено всего",
|
"crypto.stats.costBasis": "Вложено всего",
|
||||||
@@ -626,6 +632,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.form.pricePerUnitLabel": "Price per unit",
|
"crypto.form.pricePerUnitLabel": "Price per unit",
|
||||||
"crypto.form.dateLabel": "Date",
|
"crypto.form.dateLabel": "Date",
|
||||||
"crypto.form.noteLabel": "Note (optional)",
|
"crypto.form.noteLabel": "Note (optional)",
|
||||||
|
"crypto.form.networkLabel": "Network (optional)",
|
||||||
|
"crypto.form.networkPlaceholder": "e.g. Ethereum, Tron…",
|
||||||
"crypto.form.addTitle": "Add {{name}}",
|
"crypto.form.addTitle": "Add {{name}}",
|
||||||
"crypto.form.tradeTitle": "Trade: {{name}}",
|
"crypto.form.tradeTitle": "Trade: {{name}}",
|
||||||
"crypto.form.editTradeTitle": "Edit trade: {{name}}",
|
"crypto.form.editTradeTitle": "Edit trade: {{name}}",
|
||||||
@@ -642,6 +650,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.table.holdings": "Holdings",
|
"crypto.table.holdings": "Holdings",
|
||||||
"crypto.table.avgBuyPrice": "Avg. Price",
|
"crypto.table.avgBuyPrice": "Avg. Price",
|
||||||
"crypto.table.profitLoss": "Profit/Loss",
|
"crypto.table.profitLoss": "Profit/Loss",
|
||||||
|
"crypto.table.network": "Network",
|
||||||
|
"crypto.table.addNetwork": "+ Network",
|
||||||
"crypto.table.risk": "Risk",
|
"crypto.table.risk": "Risk",
|
||||||
"crypto.table.actions": "Actions",
|
"crypto.table.actions": "Actions",
|
||||||
"crypto.riskAllocationOfPortfolio": "of crypto portfolio",
|
"crypto.riskAllocationOfPortfolio": "of crypto portfolio",
|
||||||
@@ -656,6 +666,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.overview.history": "History",
|
"crypto.overview.history": "History",
|
||||||
"crypto.overview.allocation": "Allocation",
|
"crypto.overview.allocation": "Allocation",
|
||||||
"crypto.overview.risk": "Risk levels",
|
"crypto.overview.risk": "Risk levels",
|
||||||
|
"crypto.overview.networks": "Networks",
|
||||||
|
"crypto.networkAllocation.unset": "No network set",
|
||||||
"crypto.allocation.other": "Other",
|
"crypto.allocation.other": "Other",
|
||||||
"crypto.stats.allTimeProfit": "All-time profit",
|
"crypto.stats.allTimeProfit": "All-time profit",
|
||||||
"crypto.stats.costBasis": "Total invested",
|
"crypto.stats.costBasis": "Total invested",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useCryptoPortfolios,
|
useCryptoPortfolios,
|
||||||
useDeleteCryptoHolding,
|
useDeleteCryptoHolding,
|
||||||
useRefreshCryptoPrices,
|
useRefreshCryptoPrices,
|
||||||
|
useUpdateCryptoHolding,
|
||||||
useUpdateCryptoHoldingRisk,
|
useUpdateCryptoHoldingRisk,
|
||||||
} from "@/hooks/useCrypto";
|
} from "@/hooks/useCrypto";
|
||||||
import { getIntlLocale } from "@/lib/format";
|
import { getIntlLocale } from "@/lib/format";
|
||||||
@@ -46,6 +47,7 @@ export function CryptoPage() {
|
|||||||
const refresh = useRefreshCryptoPrices();
|
const refresh = useRefreshCryptoPrices();
|
||||||
const deleteHolding = useDeleteCryptoHolding();
|
const deleteHolding = useDeleteCryptoHolding();
|
||||||
const updateRisk = useUpdateCryptoHoldingRisk();
|
const updateRisk = useUpdateCryptoHoldingRisk();
|
||||||
|
const updateHolding = useUpdateCryptoHolding();
|
||||||
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null);
|
const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null);
|
||||||
@@ -86,6 +88,10 @@ export function CryptoPage() {
|
|||||||
updateRisk.mutate({ assetId: holding.asset_id, riskLevel });
|
updateRisk.mutate({ assetId: holding.asset_id, riskLevel });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleNetworkChange(holding: CryptoHolding, network: string | null) {
|
||||||
|
updateHolding.mutate({ assetId: holding.asset_id, input: { network } });
|
||||||
|
}
|
||||||
|
|
||||||
function handleEditTransaction(transaction: CryptoTransaction) {
|
function handleEditTransaction(transaction: CryptoTransaction) {
|
||||||
setEditingTransaction(transaction);
|
setEditingTransaction(transaction);
|
||||||
setTradingHolding(historyHolding);
|
setTradingHolding(historyHolding);
|
||||||
@@ -167,6 +173,7 @@ export function CryptoPage() {
|
|||||||
onViewHistory={setHistoryHolding}
|
onViewHistory={setHistoryHolding}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRiskChange={handleRiskChange}
|
onRiskChange={handleRiskChange}
|
||||||
|
onNetworkChange={handleNetworkChange}
|
||||||
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
|
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -445,6 +445,11 @@ export interface CryptoHolding {
|
|||||||
// Lives on the underlying Asset — edited via PATCH /assets/{asset_id},
|
// Lives on the underlying Asset — edited via PATCH /assets/{asset_id},
|
||||||
// same endpoint every other Asset already uses.
|
// same endpoint every other Asset already uses.
|
||||||
risk_level: RiskLevel;
|
risk_level: RiskLevel;
|
||||||
|
// Free-form chain name (e.g. "Ethereum", "Tron") — not validated against
|
||||||
|
// a fixed list, since the same coin can carry different bridge/
|
||||||
|
// counterparty risk per chain and risk_level alone can't capture that.
|
||||||
|
// Edited via PATCH /crypto/holdings/{asset_id}.
|
||||||
|
network: string | null;
|
||||||
// Both derived from the buy/sell log (see CryptoTransaction) — never
|
// Both derived from the buy/sell log (see CryptoTransaction) — never
|
||||||
// edited directly.
|
// edited directly.
|
||||||
quantity: string;
|
quantity: string;
|
||||||
@@ -483,6 +488,12 @@ export interface CryptoHoldingCreateInput {
|
|||||||
// Omit to default to "high" server-side — crypto's own textbook risk
|
// Omit to default to "high" server-side — crypto's own textbook risk
|
||||||
// tier — but overridable per coin (a large-cap isn't a memecoin).
|
// tier — but overridable per coin (a large-cap isn't a memecoin).
|
||||||
risk_level?: RiskLevel;
|
risk_level?: RiskLevel;
|
||||||
|
// Free text, e.g. "Ethereum" or "Tron" — see CryptoHolding.network.
|
||||||
|
network?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CryptoHoldingUpdateInput {
|
||||||
|
network?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CryptoTransaction {
|
export interface CryptoTransaction {
|
||||||
|
|||||||
Reference in New Issue
Block a user