mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: портфели в разделе «Криптовалюта»
Криптовалюту теперь можно разделять на отдельные портфели (например, долгосрочные инвестиции, накопления на недвижимость, спекулятивные монеты) вместо одного общего списка — по тому же принципу, что уже есть у счетов (Account): у портфеля есть имя, цветная метка и архивация, каждая монета обязательно принадлежит одному портфелю, один и тот же токен можно держать в нескольких портфелях сразу.
This commit is contained in:
@@ -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