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

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

View File

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

View File

@@ -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}`);
}

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -2,15 +2,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
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() {

View File

@@ -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",

View File

@@ -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}

View File

@@ -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;